diff --git a/CLA-SIGNATORIES.md b/CLA-SIGNATORIES.md index 7590952..e0391aa 100644 --- a/CLA-SIGNATORIES.md +++ b/CLA-SIGNATORIES.md @@ -13,3 +13,4 @@ before signing. | Name | E-mail(s) | GitHub | Signed | CLA version | Covers from | |---|---|---|---|---|---| | Yueqi Chen | yueqi.chen@colorado.edu, yueqichen.0x0@gmail.com | lewisychen | 2026-09-07 | 1.0 | first commit | +| Qinrun Dai | mouseisnotalwaysmouse@outlook.com, dai@derecho2.hsn.de.hpc.ucar.edu | second5t | 2026-09-08 | 1.0 | 2aa9a82 | diff --git a/corpus/baseline.json b/corpus/baseline.json index a04c591..2d2f4e7 100644 --- a/corpus/baseline.json +++ b/corpus/baseline.json @@ -2,41 +2,17 @@ "cases": { "bspline": { "bare_files": 0, - "blocks": 647, - "deferred": 43, + "blocks": 685, + "deferred": 4, "refusals": [ - [ - "seq-assoc: element of w for an assumed-size dummy is only a view when both are rank-N", - 25 - ], - [ - "seq-assoc: element of a for an assumed-size dummy is only a view when both are rank-N", - 6 - ], - [ - "seq-assoc: element of g for an assumed-size dummy is only a view when both are rank-N", - 4 - ], - [ - "seq-assoc: element of vnikx for an assumed-size dummy is only a view when both are rank-N", - 2 - ], [ "size with a dim= or mask= keyword", 2 ], - [ - "seq-assoc: element of u for an assumed-size dummy is only a view when both are rank-N", - 1 - ], [ "seq-assoc target: w is not rank-N and not at a lower bound", 1 ], - [ - "dbfqad/P001: local parameter min_tol (literal 'X' was never hoisted, so it has no name to ", - 1 - ], [ "write with IOSTAT= control", 1 @@ -60,7 +36,7 @@ "stopped_by": "differential.bitexact", "verdicts": { "differential.bitexact": { - "detail": "8 subprogram(s) could not be compared: daxpy: 7 of 10 trial(s) were compared only after the free extent(s) were moved off the configured values; the 80 point(s", + "detail": "8 subprogram(s) could not be compared: daxpy: 19 draw(s) were declined (19 subscript past extent) to compare 10 trial(s); the trials compared are a minority of ", "metrics": { "bit_exact": 160, "declined": { @@ -77,22 +53,22 @@ "reference_isolation": "process", "subprograms": { "dasum": { - "error": "7 of 10 trial(s) were compared only after the free extent(s) were moved off the configured values; the 10 point(s) that fit are not evidence at those extents. Pin `dims` to extents the subprogram takes" + "error": "17 draw(s) were declined (17 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" }, "daxpy": { - "error": "7 of 10 trial(s) were compared only after the free extent(s) were moved off the configured values; the 80 point(s) that fit are not evidence at those extents. Pin `dims` to extents the subprogram takes" + "error": "19 draw(s) were declined (19 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" }, "dcopy": { - "error": "7 of 10 trial(s) were compared only after the free extent(s) were moved off the configured values; the 80 point(s) that fit are not evidence at those extents. Pin `dims` to extents the subprogram takes" + "error": "19 draw(s) were declined (19 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" }, "ddot": { - "error": "7 of 10 trial(s) were compared only after the free extent(s) were moved off the configured values; the 10 point(s) that fit are not evidence at those extents. Pin `dims` to extents the subprogram takes" + "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 'radix' is not defined" + "error": "candidate raised: NameError: name 'minexponent' is not defined" }, "drotm": { - "error": "9 of 10 trial(s) were compared only after the free extent(s) were moved off the configured values; the 160 point(s) that fit are not evidence at those extents. Pin `dims` to extents the subprogram takes" + "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" }, "drotmg": { "bit_exact": 80, @@ -119,14 +95,14 @@ "nan_mismatch": 0, "points": 80, "redrawn": 9, - "reshaped": 4, + "reshaped": 0, "shaped": 0 }, "dswap": { - "error": "7 of 10 trial(s) were compared only after the free extent(s) were moved off the configured values; the 160 point(s) that fit are not evidence at those extents. Pin `dims` to extents the subprogram takes" + "error": "22 draw(s) were declined (22 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" }, "idamax": { - "error": "7 of 10 trial(s) were compared only after the free extent(s) were moved off the configured values; the 10 point(s) that fit are not evidence at those extents. Pin `dims` to extents the subprogram takes" + "error": "26 draw(s) were declined (26 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" } }, "trials": 10 @@ -147,28 +123,59 @@ } }, "fortran:bspline_defc_module": { - "deferred": 38, + "deferred": 0, "imports": true, - "mechanical": false, + "mechanical": true, "parses": true, "stages": { "frontend/fortran": "ok", + "oracle/f2py-golden": "ok", "store/fs-evidence": "ok", "transform/translate.numpy": "ok", - "verifier/static.rwset": "failed" + "verifier/differential.bitexact": "failed", + "verifier/static.rwset": "ok" }, - "stopped_by": "static.rwset", + "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", + "metrics": { + "bit_exact": 0, + "declined": {}, + "input_profile": null, + "integer_mismatch": 0, + "integer_points": 0, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 0, + "redrawn": 0, + "reference_isolation": "process", + "subprograms": { + "dcv": { + "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)" + }, + "dfc": { + "error": "candidate raised: ValueError: can only specify one unknown dimension" + } + }, + "trials": 10 + }, + "passed": false + }, "static.rwset": { - "detail": "1/290 blocks disagree: sort_ascending/B002", + "detail": "328 blocks match", "metrics": { - "blocks_checked": 290, - "blocks_deferred": 38, - "blocks_matched": 289, + "blocks_checked": 328, + "blocks_deferred": 0, + "blocks_matched": 328, "blocks_waived": 0, "reads_excused_by_stubs": 0 }, - "passed": false + "passed": true } } }, @@ -200,28 +207,29 @@ } }, "fortran:bspline_sub_module": { - "deferred": 5, + "deferred": 4, "imports": true, "mechanical": false, "parses": true, "stages": { "frontend/fortran": "ok", + "oracle/f2py-golden": "failed", "store/fs-evidence": "ok", "transform/translate.numpy": "ok", - "verifier/static.rwset": "failed" + "verifier/static.rwset": "ok" }, - "stopped_by": "static.rwset", + "stopped_by": "f2py-golden", "verdicts": { "static.rwset": { - "detail": "3/313 blocks disagree: check/B001, dbsgq8/B009, dbsgq8/B010", + "detail": "313 blocks match", "metrics": { "blocks_checked": 313, - "blocks_deferred": 5, - "blocks_matched": 310, + "blocks_deferred": 4, + "blocks_matched": 313, "blocks_waived": 0, "reads_excused_by_stubs": 0 }, - "passed": false + "passed": true } } } @@ -401,16 +409,12 @@ }, "fftpack": { "bare_files": 50, - "blocks": 20, - "deferred": 16, + "blocks": 28, + "deferred": 8, "refusals": [ [ "array of declared type 'X': no NumPy dtype spells it", 8 - ], - [ - "call to external subroutine 'X'", - 8 ] ], "status": "failed", @@ -449,48 +453,50 @@ "parses": true, "stages": { "frontend/fortran": "ok", + "oracle/f2py-golden": "failed", "store/fs-evidence": "ok", "transform/translate.numpy": "ok", - "verifier/static.rwset": "failed" + "verifier/static.rwset": "ok" }, - "stopped_by": "static.rwset", + "stopped_by": "f2py-golden", "verdicts": { "static.rwset": { - "detail": "2/2 blocks disagree: dct_rk/B001, idct_rk/B001", + "detail": "2 blocks match", "metrics": { "blocks_checked": 2, "blocks_deferred": 4, - "blocks_matched": 0, + "blocks_matched": 2, "blocks_waived": 0, "reads_excused_by_stubs": 0 }, - "passed": false + "passed": true } } }, "fortran:fftpack_fft": { - "deferred": 3, + "deferred": 1, "imports": true, "mechanical": false, "parses": true, "stages": { "frontend/fortran": "ok", + "oracle/f2py-golden": "failed", "store/fs-evidence": "ok", "transform/translate.numpy": "ok", - "verifier/static.rwset": "failed" + "verifier/static.rwset": "ok" }, - "stopped_by": "static.rwset", + "stopped_by": "f2py-golden", "verdicts": { "static.rwset": { - "detail": "1/2 blocks disagree: fft_rk/B001", + "detail": "4 blocks match", "metrics": { - "blocks_checked": 2, - "blocks_deferred": 3, - "blocks_matched": 1, + "blocks_checked": 4, + "blocks_deferred": 1, + "blocks_matched": 4, "blocks_waived": 0, "reads_excused_by_stubs": 0 }, - "passed": false + "passed": true } } }, @@ -522,28 +528,29 @@ } }, "fortran:fftpack_ifft": { - "deferred": 3, + "deferred": 1, "imports": true, "mechanical": false, "parses": true, "stages": { "frontend/fortran": "ok", + "oracle/f2py-golden": "failed", "store/fs-evidence": "ok", "transform/translate.numpy": "ok", - "verifier/static.rwset": "failed" + "verifier/static.rwset": "ok" }, - "stopped_by": "static.rwset", + "stopped_by": "f2py-golden", "verdicts": { "static.rwset": { - "detail": "1/2 blocks disagree: ifft_rk/B001", + "detail": "4 blocks match", "metrics": { - "blocks_checked": 2, - "blocks_deferred": 3, - "blocks_matched": 1, + "blocks_checked": 4, + "blocks_deferred": 1, + "blocks_matched": 4, "blocks_waived": 0, "reads_excused_by_stubs": 0 }, - "passed": false + "passed": true } } }, @@ -575,54 +582,56 @@ } }, "fortran:fftpack_irfft": { - "deferred": 3, + "deferred": 1, "imports": true, "mechanical": false, "parses": true, "stages": { "frontend/fortran": "ok", + "oracle/f2py-golden": "failed", "store/fs-evidence": "ok", "transform/translate.numpy": "ok", - "verifier/static.rwset": "failed" + "verifier/static.rwset": "ok" }, - "stopped_by": "static.rwset", + "stopped_by": "f2py-golden", "verdicts": { "static.rwset": { - "detail": "1/2 blocks disagree: irfft_rk/B001", + "detail": "4 blocks match", "metrics": { - "blocks_checked": 2, - "blocks_deferred": 3, - "blocks_matched": 1, + "blocks_checked": 4, + "blocks_deferred": 1, + "blocks_matched": 4, "blocks_waived": 0, "reads_excused_by_stubs": 0 }, - "passed": false + "passed": true } } }, "fortran:fftpack_rfft": { - "deferred": 3, + "deferred": 1, "imports": true, "mechanical": false, "parses": true, "stages": { "frontend/fortran": "ok", + "oracle/f2py-golden": "failed", "store/fs-evidence": "ok", "transform/translate.numpy": "ok", - "verifier/static.rwset": "failed" + "verifier/static.rwset": "ok" }, - "stopped_by": "static.rwset", + "stopped_by": "f2py-golden", "verdicts": { "static.rwset": { - "detail": "1/2 blocks disagree: rfft_rk/B001", + "detail": "4 blocks match", "metrics": { - "blocks_checked": 2, - "blocks_deferred": 3, - "blocks_matched": 1, + "blocks_checked": 4, + "blocks_deferred": 1, + "blocks_matched": 4, "blocks_waived": 0, "reads_excused_by_stubs": 0 }, - "passed": false + "passed": true } } }, @@ -657,21 +666,13 @@ }, "fortran-utils": { "bare_files": 0, - "blocks": 494, - "deferred": 108, + "blocks": 540, + "deferred": 54, "refusals": [ [ "call to external subroutine 'X'", 48 ], - [ - "generic 'X': ambiguous between ['X', 'X']", - 34 - ], - [ - "READ writes its item list; an I/O stub would drop the writes", - 8 - ], [ "cmplx(x, y): the two-part form is not spelled; cmplx(x, kind=k) is", 3 @@ -679,54 +680,6 @@ [ "goto N is not a loop-exit pattern", 3 - ], - [ - "inquire writes OPENED=", - 1 - ], - [ - "formatted internal write with a non-literal format", - 1 - ], - [ - "qr_fact/P001: local array tau: extent not resolvable (dim expr 'X')", - 1 - ], - [ - "inquire writes POS=", - 1 - ], - [ - "write with ADVANCE= control", - 1 - ], - [ - "spline3/P001: local array c: extent not resolvable (dim expr 'X')", - 1 - ], - [ - "spline3ders/P001: local array c: extent not resolvable (dim expr 'X')", - 1 - ], - [ - "spline3pars/P001: local array as: extent not resolvable (dim expr 'X')", - 1 - ], - [ - "spline3pars/P002: local array bs: extent not resolvable (dim expr 'X')", - 1 - ], - [ - "spline3pars/P003: local array cs: extent not resolvable (dim expr 'X')", - 1 - ], - [ - "spline3pars/P004: local array ipiv2: extent not resolvable (dim expr 'X')", - 1 - ], - [ - "spline3pars/P005: local array bmat: extent not resolvable (dim expr 'X')", - 1 ] ], "status": "failed", @@ -759,9 +712,8 @@ } }, "fortran:linalg": { - "deferred": 66, - "import_error": "ModuleNotFoundError: No module named 'lapack_numpy'", - "imports": false, + "deferred": 35, + "imports": true, "mechanical": false, "parses": true, "stages": { @@ -773,11 +725,11 @@ "stopped_by": "static.rwset", "verdicts": { "static.rwset": { - "detail": "13/229 blocks disagree: deig/B011, zeig/B012, deigvals/B010, zeigvals/B011, deigh_simple/B009 (+8 more)", + "detail": "5/259 blocks disagree: ddiag/B004, zdiag/B004, qr_fact/B001, qr_fact/B006, qr_fact/B015", "metrics": { - "blocks_checked": 229, - "blocks_deferred": 66, - "blocks_matched": 216, + "blocks_checked": 259, + "blocks_deferred": 35, + "blocks_matched": 254, "blocks_waived": 0, "reads_excused_by_stubs": 0 }, @@ -792,13 +744,99 @@ "parses": true, "stages": { "frontend/fortran": "ok", - "oracle/f2py-golden": "failed", + "oracle/f2py-golden": "ok", "store/fs-evidence": "ok", "transform/translate.numpy": "ok", + "verifier/differential.bitexact": "failed", "verifier/static.rwset": "ok" }, - "stopped_by": "f2py-golden", + "stopped_by": "differential.bitexact", "verdicts": { + "differential.bitexact": { + "detail": "2 subprogram(s) could not be compared: meshexp: 16 draw(s) were declined (16 error stop) to compare 10 trial(s); the trials compared are a minority of what the ", + "metrics": { + "bit_exact": 1434, + "declined": { + "NaN on both sides": 1, + "error stop": 8 + }, + "input_profile": null, + "integer_mismatch": 0, + "integer_points": 10, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 1434, + "redrawn": 9, + "reference_isolation": "process", + "subprograms": { + "get_meshexp_pars": { + "bit_exact": 40, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 10, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 40, + "redrawn": 0, + "reshaped": 0, + "shaped": 0 + }, + "linspace": { + "bit_exact": 57, + "declined": { + "NaN on both sides": 1 + }, + "integer_mismatch": 0, + "integer_points": 0, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 57, + "redrawn": 1, + "reshaped": 0, + "shaped": 0 + }, + "meshexp": { + "error": "16 draw(s) were declined (16 error stop) to compare 10 trial(s); the trials compared are a minority of what the configured draw produces and are not evidence about the rest. Narrow the draw with `ranges`, or pin `dims`, to values the subprogram takes" + }, + "meshexp_der": { + "bit_exact": 57, + "declined": { + "error stop": 8 + }, + "integer_mismatch": 0, + "integer_points": 0, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 57, + "redrawn": 8, + "reshaped": 0, + "shaped": 0 + }, + "meshexp_der2": { + "error": "16 draw(s) were declined (16 error stop) to compare 10 trial(s); the trials compared are a minority of what the configured draw produces and are not evidence about the rest. Narrow the draw with `ranges`, or pin `dims`, to values the subprogram takes" + }, + "meshgrid": { + "bit_exact": 1280, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 0, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 1280, + "redrawn": 0, + "reshaped": 0, + "shaped": 0 + } + }, + "trials": 10 + }, + "passed": false + }, "static.rwset": { "detail": "10 blocks match", "metrics": { @@ -819,13 +857,40 @@ "parses": true, "stages": { "frontend/fortran": "ok", - "oracle/f2py-golden": "failed", + "oracle/f2py-golden": "ok", "store/fs-evidence": "ok", "transform/translate.numpy": "ok", + "verifier/differential.bitexact": "failed", "verifier/static.rwset": "ok" }, - "stopped_by": "f2py-golden", + "stopped_by": "differential.bitexact", "verdicts": { + "differential.bitexact": { + "detail": "2 subprogram(s) could not be compared: bisect: 24 draw(s) were declined (18 error stop) to compare 10 trial(s); the trials compared are a minority of what the c", + "metrics": { + "bit_exact": 0, + "declined": {}, + "input_profile": null, + "integer_mismatch": 0, + "integer_points": 0, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 0, + "redrawn": 0, + "reference_isolation": "in-process (call-back arguments: bisect, secant)", + "subprograms": { + "bisect": { + "error": "24 draw(s) were declined (18 error stop) to compare 10 trial(s); the trials compared are a minority of what the configured draw produces and are not evidence about the rest. Narrow the draw with `ranges`, or pin `dims`, to values the subprogram takes" + }, + "secant": { + "error": "11 draw(s) were declined (11 error stop) to compare 10 trial(s); the trials compared are a minority of what the configured draw produces and are not evidence about the rest. Narrow the draw with `ranges`, or pin `dims`, to values the subprogram takes" + } + }, + "trials": 10 + }, + "passed": false + }, "static.rwset": { "detail": "16 blocks match", "metrics": { @@ -840,174 +905,78 @@ } }, "fortran:ppm": { - "deferred": 7, + "deferred": 0, "imports": true, - "mechanical": false, + "mechanical": true, "parses": true, "stages": { "frontend/fortran": "ok", + "oracle/f2py-golden": "ok", "store/fs-evidence": "ok", "transform/translate.numpy": "ok", - "verifier/static.rwset": "failed" - }, - "stopped_by": "static.rwset", - "verdicts": { - "static.rwset": { - "detail": "6/14 blocks disagree: loadppm/B001, loadppm/B008, loadppm/B009, loadppm/B013, saveppm/B001 (+1 more)", - "metrics": { - "blocks_checked": 14, - "blocks_deferred": 7, - "blocks_matched": 8, - "blocks_waived": 0, - "reads_excused_by_stubs": 0 - }, - "passed": false - } - } - }, - "fortran:sorting": { - "deferred": 1, - "imports": true, - "mechanical": false, - "parses": true, - "stages": { - "frontend/fortran": "ok", - "oracle/f2py-golden": "failed", - "store/fs-evidence": "ok", - "transform/translate.numpy": "ok", - "verifier/static.rwset": "ok" + "verifier/differential.bitexact": "ok", + "verifier/static.rwset": "ok", + "verifier/symbolic.notary": "ok" }, - "stopped_by": "f2py-golden", + "stopped_by": null, "verdicts": { - "static.rwset": { - "detail": "39 blocks match", + "differential.bitexact": { + "detail": "6520 points across 1 subprogram(s), all bit-exact; 1 subprogram(s) ungated, no reference: loadppm (character argument filename: no generated draw for one)", "metrics": { - "blocks_checked": 39, - "blocks_deferred": 1, - "blocks_matched": 39, - "blocks_waived": 0, - "reads_excused_by_stubs": 0 + "bit_exact": 6520, + "declined": {}, + "input_profile": null, + "integer_mismatch": 0, + "integer_points": 6520, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 6520, + "redrawn": 0, + "reference_isolation": "process", + "subprograms": { + "saveppm": { + "bit_exact": 6520, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 6520, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 6520, + "redrawn": 0, + "reshaped": 0, + "shaped": 0 + } + }, + "trials": 10, + "ungated": { + "loadppm": "character argument filename: no generated draw for one" + } }, "passed": true - } - } - }, - "fortran:special": { - "deferred": 19, - "imports": true, - "mechanical": false, - "parses": true, - "stages": { - "frontend/fortran": "ok", - "store/fs-evidence": "ok", - "transform/translate.numpy": "ok", - "verifier/static.rwset": "failed" - }, - "stopped_by": "static.rwset", - "verdicts": { - "static.rwset": { - "detail": "2/92 blocks disagree: bessel_j0_zeros/B001, spherical_bessel_jn_zeros/B001", - "metrics": { - "blocks_checked": 92, - "blocks_deferred": 19, - "blocks_matched": 90, - "blocks_waived": 0, - "reads_excused_by_stubs": 0 - }, - "passed": false - } - } - }, - "fortran:splines": { - "deferred": 10, - "imports": true, - "mechanical": false, - "parses": true, - "stages": { - "frontend/fortran": "ok", - "oracle/f2py-golden": "failed", - "store/fs-evidence": "ok", - "transform/translate.numpy": "ok", - "verifier/static.rwset": "ok" - }, - "stopped_by": "f2py-golden", - "verdicts": { + }, "static.rwset": { - "detail": "51 blocks match", + "detail": "21 blocks match", "metrics": { - "blocks_checked": 51, - "blocks_deferred": 10, - "blocks_matched": 51, + "blocks_checked": 21, + "blocks_deferred": 0, + "blocks_matched": 21, "blocks_waived": 0, "reads_excused_by_stubs": 0 }, "passed": true - } - } - }, - "fortran:types": { - "deferred": 0, - "imports": true, - "mechanical": true, - "parses": true, - "stages": { - "frontend/fortran": "ok", - "oracle/f2py-golden": "failed", - "store/fs-evidence": "ok", - "transform/translate.numpy": "ok", - "verifier/static.rwset": "ok" - }, - "stopped_by": "f2py-golden", - "verdicts": { - "static.rwset": { - "detail": "0 blocks match", + }, + "symbolic.notary": { + "detail": "no rewrites to notarize; the translation is print-order faithful", "metrics": { - "blocks_checked": 0, - "blocks_deferred": 0, - "blocks_matched": 0, - "blocks_waived": 0, - "reads_excused_by_stubs": 0 + "rewrites": 0 }, "passed": true } } }, - "fortran:utils": { - "deferred": 5, - "imports": true, - "mechanical": false, - "parses": true, - "stages": { - "frontend/fortran": "ok", - "store/fs-evidence": "ok", - "transform/translate.numpy": "ok", - "verifier/static.rwset": "failed" - }, - "stopped_by": "static.rwset", - "verdicts": { - "static.rwset": { - "detail": "7/43 blocks disagree: stop_error/B001, loadtxt/B001, loadtxt/B005, loadtxt/B008, loadtxt/B011 (+2 more)", - "metrics": { - "blocks_checked": 43, - "blocks_deferred": 5, - "blocks_matched": 36, - "blocks_waived": 0, - "reads_excused_by_stubs": 0 - }, - "passed": false - } - } - } - } - }, - "minpack": { - "bare_files": 0, - "blocks": 104, - "deferred": 0, - "refusals": [], - "status": "passed", - "units": { - "fortran:minpack_module": { + "fortran:sorting": { "deferred": 0, "imports": true, "mechanical": true, @@ -1024,273 +993,165 @@ "stopped_by": null, "verdicts": { "differential.bitexact": { - "detail": "18040 points across 22 subprogram(s), all bit-exact; 7 draw(s) declined and drawn again (7 NaN on both sides)", + "detail": "14560 points across 12 subprogram(s), all bit-exact", "metrics": { - "bit_exact": 18040, - "declined": { - "NaN on both sides": 7 - }, - "input_profile": "recast_inputs.py", + "bit_exact": 14560, + "declined": {}, + "input_profile": null, "integer_mismatch": 0, - "integer_points": 760, + "integer_points": 480, "max_rel": 0.0, "max_ulp": 0, "nan_mismatch": 0, - "points": 18040, - "redrawn": 7, - "reference_isolation": "in-process (call-back arguments: fdjac1, fdjac2, hybrd, hybrd1, hybrj, hybrj1, lmder, lmder1, lmdif, lmdif1, lmstr, lmstr1)", + "points": 14560, + "redrawn": 0, + "reference_isolation": "process", "subprograms": { - "chkder": { - "bit_exact": 160, + "iargsort": { + "bit_exact": 80, "declined": {}, "integer_mismatch": 0, - "integer_points": 0, + "integer_points": 80, "max_rel": 0.0, "max_ulp": 0, "nan_mismatch": 0, - "points": 160, + "points": 80, "redrawn": 0, "reshaped": 0, "shaped": 0 }, - "dogleg": { - "bit_exact": 240, + "rargsort": { + "bit_exact": 80, "declined": {}, "integer_mismatch": 0, - "integer_points": 0, + "integer_points": 80, "max_rel": 0.0, "max_ulp": 0, "nan_mismatch": 0, - "points": 240, + "points": 80, "redrawn": 0, "reshaped": 0, - "shaped": 10 + "shaped": 0 }, - "enorm": { - "bit_exact": 10, + "sortinumcnumpairs": { + "bit_exact": 240, "declined": {}, "integer_mismatch": 0, - "integer_points": 0, + "integer_points": 80, "max_rel": 0.0, "max_ulp": 0, "nan_mismatch": 0, - "points": 10, + "points": 240, "redrawn": 0, "reshaped": 0, "shaped": 0 }, - "fdjac1": { - "bit_exact": 890, + "sortinummatpairs": { + "bit_exact": 5200, "declined": {}, "integer_mismatch": 0, - "integer_points": 10, + "integer_points": 80, "max_rel": 0.0, "max_ulp": 0, "nan_mismatch": 0, - "points": 890, + "points": 5200, "redrawn": 0, "reshaped": 0, "shaped": 0 }, - "fdjac2": { - "bit_exact": 810, + "sortinums": { + "bit_exact": 80, "declined": {}, "integer_mismatch": 0, - "integer_points": 10, + "integer_points": 80, "max_rel": 0.0, "max_ulp": 0, "nan_mismatch": 0, - "points": 810, + "points": 80, "redrawn": 0, "reshaped": 0, "shaped": 0 }, - "hybrd": { - "bit_exact": 1380, + "sortinumvecpairs": { + "bit_exact": 720, "declined": {}, "integer_mismatch": 0, - "integer_points": 20, + "integer_points": 80, "max_rel": 0.0, "max_ulp": 0, "nan_mismatch": 0, - "points": 1380, + "points": 720, "redrawn": 0, "reshaped": 0, "shaped": 0 }, - "hybrd1": { - "bit_exact": 250, + "sortnumcvecpairs": { + "bit_exact": 1360, "declined": {}, "integer_mismatch": 0, - "integer_points": 10, + "integer_points": 0, "max_rel": 0.0, "max_ulp": 0, "nan_mismatch": 0, - "points": 250, + "points": 1360, "redrawn": 0, "reshaped": 0, "shaped": 0 }, - "hybrj": { - "bit_exact": 1390, + "sortnummatpairs": { + "bit_exact": 5200, "declined": {}, "integer_mismatch": 0, - "integer_points": 30, + "integer_points": 0, "max_rel": 0.0, "max_ulp": 0, "nan_mismatch": 0, - "points": 1390, + "points": 5200, "redrawn": 0, "reshaped": 0, "shaped": 0 }, - "hybrj1": { - "bit_exact": 890, + "sortnumnumpairs": { + "bit_exact": 160, "declined": {}, "integer_mismatch": 0, - "integer_points": 10, + "integer_points": 0, "max_rel": 0.0, "max_ulp": 0, "nan_mismatch": 0, - "points": 890, + "points": 160, "redrawn": 0, "reshaped": 0, "shaped": 0 }, - "lmder": { - "bit_exact": 1390, + "sortnums": { + "bit_exact": 80, "declined": {}, "integer_mismatch": 0, - "integer_points": 110, + "integer_points": 0, "max_rel": 0.0, "max_ulp": 0, "nan_mismatch": 0, - "points": 1390, + "points": 80, "redrawn": 0, "reshaped": 0, "shaped": 0 }, - "lmder1": { - "bit_exact": 970, + "sortnumvecpairs": { + "bit_exact": 720, "declined": {}, "integer_mismatch": 0, - "integer_points": 90, + "integer_points": 0, "max_rel": 0.0, "max_ulp": 0, "nan_mismatch": 0, - "points": 970, + "points": 720, "redrawn": 0, "reshaped": 0, "shaped": 0 }, - "lmdif": { - "bit_exact": 1380, - "declined": {}, - "integer_mismatch": 0, - "integer_points": 100, - "max_rel": 0.0, - "max_ulp": 0, - "nan_mismatch": 0, - "points": 1380, - "redrawn": 0, - "reshaped": 0, - "shaped": 0 - }, - "lmdif1": { - "bit_exact": 330, - "declined": {}, - "integer_mismatch": 0, - "integer_points": 90, - "max_rel": 0.0, - "max_ulp": 0, - "nan_mismatch": 0, - "points": 330, - "redrawn": 0, - "reshaped": 0, - "shaped": 0 - }, - "lmpar": { - "bit_exact": 970, - "declined": { - "NaN on both sides": 7 - }, - "integer_mismatch": 0, - "integer_points": 0, - "max_rel": 0.0, - "max_ulp": 0, - "nan_mismatch": 0, - "points": 970, - "redrawn": 7, - "reshaped": 0, - "shaped": 0 - }, - "lmstr": { - "bit_exact": 1390, - "declined": {}, - "integer_mismatch": 0, - "integer_points": 110, - "max_rel": 0.0, - "max_ulp": 0, - "nan_mismatch": 0, - "points": 1390, - "redrawn": 0, - "reshaped": 0, - "shaped": 0 - }, - "lmstr1": { - "bit_exact": 970, - "declined": {}, - "integer_mismatch": 0, - "integer_points": 90, - "max_rel": 0.0, - "max_ulp": 0, - "nan_mismatch": 0, - "points": 970, - "redrawn": 0, - "reshaped": 0, - "shaped": 0 - }, - "qform": { - "bit_exact": 720, - "declined": {}, - "integer_mismatch": 0, - "integer_points": 0, - "max_rel": 0.0, - "max_ulp": 0, - "nan_mismatch": 0, - "points": 720, - "redrawn": 0, - "reshaped": 0, - "shaped": 0 - }, - "qrfac": { - "bit_exact": 960, - "declined": {}, - "integer_mismatch": 0, - "integer_points": 80, - "max_rel": 0.0, - "max_ulp": 0, - "nan_mismatch": 0, - "points": 960, - "redrawn": 0, - "reshaped": 0, - "shaped": 0 - }, - "qrsolv": { - "bit_exact": 880, - "declined": {}, - "integer_mismatch": 0, - "integer_points": 0, - "max_rel": 0.0, - "max_ulp": 0, - "nan_mismatch": 0, - "points": 880, - "redrawn": 0, - "reshaped": 0, - "shaped": 0 - }, - "r1mpyq": { - "bit_exact": 640, + "sortvecs": { + "bit_exact": 640, "declined": {}, "integer_mismatch": 0, "integer_points": 0, @@ -1301,32 +1162,6 @@ "redrawn": 0, "reshaped": 0, "shaped": 0 - }, - "r1updt": { - "bit_exact": 530, - "declined": {}, - "integer_mismatch": 0, - "integer_points": 0, - "max_rel": 0.0, - "max_ulp": 0, - "nan_mismatch": 0, - "points": 530, - "redrawn": 0, - "reshaped": 0, - "shaped": 10 - }, - "rwupdt": { - "bit_exact": 890, - "declined": {}, - "integer_mismatch": 0, - "integer_points": 0, - "max_rel": 0.0, - "max_ulp": 0, - "nan_mismatch": 0, - "points": 890, - "redrawn": 0, - "reshaped": 0, - "shaped": 0 } }, "trials": 10 @@ -1334,11 +1169,11 @@ "passed": true }, "static.rwset": { - "detail": "104 blocks match", + "detail": "40 blocks match", "metrics": { - "blocks_checked": 104, + "blocks_checked": 40, "blocks_deferred": 0, - "blocks_matched": 104, + "blocks_matched": 40, "blocks_waived": 0, "reads_excused_by_stubs": 0 }, @@ -1352,91 +1187,9 @@ "passed": true } } - } - } - }, - "numfor": { - "bare_files": 85, - "blocks": 897, - "deferred": 56, - "refusals": [ - [ - "generic 'X': no match", - 11 - ], - [ - "call to external subroutine 'X'", - 9 - ], - [ - "intrinsic subroutine 'X' has no rule", - 8 - ], - [ - "generic 'X': ambiguous between ['X', 'X']", - 7 - ], - [ - "READ writes its item list; an I/O stub would drop the writes", - 4 - ], - [ - "do with a loop control that is neither a count nor a condition", - 4 - ], - [ - "generic 'X': ambiguous between ['X', 'X', 'X', 'X']", - 2 - ], - [ - "a keyword argument is not a subscript", - 1 - ], - [ - "merge_sorted/P001: local array xo: extent not resolvable (dim expr 'X')", - 1 - ], - [ - "no statement rule for Select_Type_Construct", - 1 - ], - [ - "write with IOSTAT= control", - 1 - ], - [ - "cspl_interpdev_tab/P001: local array tofill: extent not resolvable (dim expr 'X')", - 1 - ], - [ - "cspleps/P001: local array r: extent not resolvable (dim expr 'X')", - 1 - ], - [ - "cspleps/P002: local array f: extent not resolvable (dim expr 'X')", - 1 - ], - [ - "cspleps/P003: local array s: extent not resolvable (dim expr 'X')", - 1 - ], - [ - "mti/S001: module-state initializer not renderable: 'X'", - 1 - ], - [ - "rng_init_by_scalar/P001: out-arg state: INTENT(OUT) derived-type dummy not materialized at", - 1 - ], - [ - "rng_init_by_array/P001: out-arg state: INTENT(OUT) derived-type dummy not materialized at ", - 1 - ] - ], - "status": "failed", - "units": { - "fortran:array_utils": { - "deferred": 2, + }, + "fortran:special": { + "deferred": 16, "imports": true, "mechanical": false, "parses": true, @@ -1449,11 +1202,11 @@ "stopped_by": "static.rwset", "verdicts": { "static.rwset": { - "detail": "2/69 blocks disagree: save_array1d/B021, save_array2d/B016", + "detail": "1/95 blocks disagree: sphj/B007", "metrics": { - "blocks_checked": 69, - "blocks_deferred": 2, - "blocks_matched": 67, + "blocks_checked": 95, + "blocks_deferred": 16, + "blocks_matched": 94, "blocks_waived": 0, "reads_excused_by_stubs": 0 }, @@ -1461,7 +1214,34 @@ } } }, - "fortran:arrays": { + "fortran:splines": { + "deferred": 3, + "imports": true, + "mechanical": false, + "parses": true, + "stages": { + "frontend/fortran": "ok", + "oracle/f2py-golden": "failed", + "store/fs-evidence": "ok", + "transform/translate.numpy": "ok", + "verifier/static.rwset": "ok" + }, + "stopped_by": "f2py-golden", + "verdicts": { + "static.rwset": { + "detail": "51 blocks match", + "metrics": { + "blocks_checked": 51, + "blocks_deferred": 3, + "blocks_matched": 51, + "blocks_waived": 0, + "reads_excused_by_stubs": 0 + }, + "passed": true + } + } + }, + "fortran:types": { "deferred": 0, "imports": true, "mechanical": true, @@ -1488,10 +1268,10 @@ } } }, - "fortran:basic": { - "deferred": 3, + "fortran:utils": { + "deferred": 0, "imports": true, - "mechanical": false, + "mechanical": true, "parses": true, "stages": { "frontend/fortran": "ok", @@ -1505,88 +1285,970 @@ "stopped_by": null, "verdicts": { "differential.bitexact": { - "detail": "10 points across 1 subprogram(s), all bit-exact; 1 subprogram(s) ungated, no reference: print_msg (character argument msg: no generated draw for one)", + "detail": "16730 points across 3 subprogram(s), all bit-exact; 7 draw(s) declined and drawn again (7 error stop); 13 subprogram(s) ungated, no reference: arange (u: alloca", "metrics": { - "bit_exact": 10, - "declined": {}, - "input_profile": null, + "bit_exact": 16730, + "declined": { + "error stop": 7 + }, + "input_profile": null, + "integer_mismatch": 0, + "integer_points": 16730, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 16730, + "redrawn": 7, + "reference_isolation": "process", + "subprograms": { + "assert": { + "bit_exact": 0, + "declined": { + "error stop": 7 + }, + "integer_mismatch": 0, + "integer_points": 0, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 0, + "redrawn": 7, + "reshaped": 0, + "shaped": 0 + }, + "newunit": { + "bit_exact": 10, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 10, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 10, + "redrawn": 0, + "reshaped": 0, + "shaped": 0 + }, + "savetxt": { + "bit_exact": 16720, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 16720, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 16720, + "redrawn": 0, + "reshaped": 0, + "shaped": 0 + } + }, + "trials": 10, + "ungated": { + "arange": "u: allocatable array the callee sizes", + "blank": "character argument string: no generated draw for one", + "getstring": "character argument s: no generated draw for one", + "loadtxt": "character argument filename: no generated draw for one", + "lowcase": "character argument s: no generated draw for one", + "numstrings": "character argument s: no generated draw for one", + "stop_error": "character argument msg: no generated draw for one", + "str_int": "character result, fixed at len=128 by the wrapper", + "str_real": "character result, fixed at len=128 by the wrapper", + "str_real_len": "character argument fmt: no generated draw for one", + "str_real_n": "character result, fixed at len=128 by the wrapper", + "upcase": "character argument s: no generated draw for one", + "whitechar": "character argument char: no generated draw for one" + } + }, + "passed": true + }, + "static.rwset": { + "detail": "48 blocks match", + "metrics": { + "blocks_checked": 48, + "blocks_deferred": 0, + "blocks_matched": 48, + "blocks_waived": 0, + "reads_excused_by_stubs": 0 + }, + "passed": true + }, + "symbolic.notary": { + "detail": "no rewrites to notarize; the translation is print-order faithful", + "metrics": { + "rewrites": 0 + }, + "passed": true + } + } + } + } + }, + "minpack": { + "bare_files": 0, + "blocks": 104, + "deferred": 0, + "refusals": [], + "status": "passed", + "units": { + "fortran:minpack_module": { + "deferred": 0, + "imports": true, + "mechanical": true, + "parses": true, + "stages": { + "frontend/fortran": "ok", + "oracle/f2py-golden": "ok", + "store/fs-evidence": "ok", + "transform/translate.numpy": "ok", + "verifier/differential.bitexact": "ok", + "verifier/static.rwset": "ok", + "verifier/symbolic.notary": "ok" + }, + "stopped_by": null, + "verdicts": { + "differential.bitexact": { + "detail": "18040 points across 22 subprogram(s), all bit-exact; 7 draw(s) declined and drawn again (7 NaN on both sides)", + "metrics": { + "bit_exact": 18040, + "declined": { + "NaN on both sides": 7 + }, + "input_profile": "recast_inputs.py", + "integer_mismatch": 0, + "integer_points": 760, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 18040, + "redrawn": 7, + "reference_isolation": "in-process (call-back arguments: fdjac1, fdjac2, hybrd, hybrd1, hybrj, hybrj1, lmder, lmder1, lmdif, lmdif1, lmstr, lmstr1)", + "subprograms": { + "chkder": { + "bit_exact": 160, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 0, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 160, + "redrawn": 0, + "reshaped": 0, + "shaped": 0 + }, + "dogleg": { + "bit_exact": 240, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 0, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 240, + "redrawn": 0, + "reshaped": 0, + "shaped": 10 + }, + "enorm": { + "bit_exact": 10, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 0, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 10, + "redrawn": 0, + "reshaped": 0, + "shaped": 0 + }, + "fdjac1": { + "bit_exact": 890, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 10, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 890, + "redrawn": 0, + "reshaped": 0, + "shaped": 0 + }, + "fdjac2": { + "bit_exact": 810, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 10, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 810, + "redrawn": 0, + "reshaped": 0, + "shaped": 0 + }, + "hybrd": { + "bit_exact": 1380, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 20, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 1380, + "redrawn": 0, + "reshaped": 0, + "shaped": 0 + }, + "hybrd1": { + "bit_exact": 250, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 10, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 250, + "redrawn": 0, + "reshaped": 0, + "shaped": 0 + }, + "hybrj": { + "bit_exact": 1390, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 30, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 1390, + "redrawn": 0, + "reshaped": 0, + "shaped": 0 + }, + "hybrj1": { + "bit_exact": 890, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 10, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 890, + "redrawn": 0, + "reshaped": 0, + "shaped": 0 + }, + "lmder": { + "bit_exact": 1390, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 110, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 1390, + "redrawn": 0, + "reshaped": 0, + "shaped": 0 + }, + "lmder1": { + "bit_exact": 970, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 90, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 970, + "redrawn": 0, + "reshaped": 0, + "shaped": 0 + }, + "lmdif": { + "bit_exact": 1380, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 100, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 1380, + "redrawn": 0, + "reshaped": 0, + "shaped": 0 + }, + "lmdif1": { + "bit_exact": 330, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 90, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 330, + "redrawn": 0, + "reshaped": 0, + "shaped": 0 + }, + "lmpar": { + "bit_exact": 970, + "declined": { + "NaN on both sides": 7 + }, + "integer_mismatch": 0, + "integer_points": 0, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 970, + "redrawn": 7, + "reshaped": 0, + "shaped": 0 + }, + "lmstr": { + "bit_exact": 1390, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 110, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 1390, + "redrawn": 0, + "reshaped": 0, + "shaped": 0 + }, + "lmstr1": { + "bit_exact": 970, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 90, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 970, + "redrawn": 0, + "reshaped": 0, + "shaped": 0 + }, + "qform": { + "bit_exact": 720, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 0, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 720, + "redrawn": 0, + "reshaped": 0, + "shaped": 0 + }, + "qrfac": { + "bit_exact": 960, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 80, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 960, + "redrawn": 0, + "reshaped": 0, + "shaped": 0 + }, + "qrsolv": { + "bit_exact": 880, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 0, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 880, + "redrawn": 0, + "reshaped": 0, + "shaped": 0 + }, + "r1mpyq": { + "bit_exact": 640, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 0, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 640, + "redrawn": 0, + "reshaped": 0, + "shaped": 0 + }, + "r1updt": { + "bit_exact": 530, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 0, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 530, + "redrawn": 0, + "reshaped": 0, + "shaped": 10 + }, + "rwupdt": { + "bit_exact": 890, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 0, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 890, + "redrawn": 0, + "reshaped": 0, + "shaped": 0 + } + }, + "trials": 10 + }, + "passed": true + }, + "static.rwset": { + "detail": "104 blocks match", + "metrics": { + "blocks_checked": 104, + "blocks_deferred": 0, + "blocks_matched": 104, + "blocks_waived": 0, + "reads_excused_by_stubs": 0 + }, + "passed": true + }, + "symbolic.notary": { + "detail": "no rewrites to notarize; the translation is print-order faithful", + "metrics": { + "rewrites": 0 + }, + "passed": true + } + } + } + } + }, + "numfor": { + "bare_files": 85, + "blocks": 898, + "deferred": 51, + "refusals": [ + [ + "generic 'X': no match", + 11 + ], + [ + "call to external subroutine 'X'", + 9 + ], + [ + "intrinsic subroutine 'X' has no rule", + 8 + ], + [ + "do with a loop control that is neither a count nor a condition", + 4 + ], + [ + "function rng_rand64 has OUT/INOUT dummy argument(s) state, which only a whole-statement re", + 4 + ], + [ + "generic 'X': ambiguous between ['X', 'X']", + 2 + ], + [ + "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 + ], + [ + "no statement rule for Select_Type_Construct", + 1 + ], + [ + "write with IOSTAT= control", + 1 + ], + [ + "READ with IOMSG= control", + 1 + ], + [ + "cspl_interpdev_tab/P001: local array tofill: extent not resolvable (dim expr 'X')", + 1 + ], + [ + "mti/S001: module-state initializer not renderable: 'X'", + 1 + ], + [ + "rng_init_by_scalar/P001: out-arg state: INTENT(OUT) derived-type dummy not materialized at", + 1 + ], + [ + "rng_init_by_array/P001: out-arg state: INTENT(OUT) derived-type dummy not materialized at ", + 1 + ], + [ + "function rng_rand_real2 has OUT/INOUT dummy argument(s) state, which only a whole-statemen", + 1 + ], + [ + "OPEN with ERR= specifier", + 1 + ] + ], + "status": "failed", + "units": { + "fortran:array_utils": { + "deferred": 1, + "imports": true, + "mechanical": false, + "parses": true, + "stages": { + "frontend/fortran": "ok", + "oracle/f2py-golden": "failed", + "store/fs-evidence": "ok", + "transform/translate.numpy": "ok", + "verifier/static.rwset": "ok" + }, + "stopped_by": "f2py-golden", + "verdicts": { + "static.rwset": { + "detail": "69 blocks match", + "metrics": { + "blocks_checked": 69, + "blocks_deferred": 1, + "blocks_matched": 69, + "blocks_waived": 0, + "reads_excused_by_stubs": 0 + }, + "passed": true + } + } + }, + "fortran:arrays": { + "deferred": 0, + "imports": true, + "mechanical": true, + "parses": true, + "stages": { + "frontend/fortran": "ok", + "oracle/f2py-golden": "failed", + "store/fs-evidence": "ok", + "transform/translate.numpy": "ok", + "verifier/static.rwset": "ok" + }, + "stopped_by": "f2py-golden", + "verdicts": { + "static.rwset": { + "detail": "0 blocks match", + "metrics": { + "blocks_checked": 0, + "blocks_deferred": 0, + "blocks_matched": 0, + "blocks_waived": 0, + "reads_excused_by_stubs": 0 + }, + "passed": true + } + } + }, + "fortran:basic": { + "deferred": 3, + "imports": true, + "mechanical": false, + "parses": true, + "stages": { + "frontend/fortran": "ok", + "oracle/f2py-golden": "ok", + "store/fs-evidence": "ok", + "transform/translate.numpy": "ok", + "verifier/differential.bitexact": "ok", + "verifier/static.rwset": "ok", + "verifier/symbolic.notary": "ok" + }, + "stopped_by": null, + "verdicts": { + "differential.bitexact": { + "detail": "10 points across 1 subprogram(s), all bit-exact; 1 subprogram(s) ungated, no reference: print_msg (character argument msg: no generated draw for one)", + "metrics": { + "bit_exact": 10, + "declined": {}, + "input_profile": null, + "integer_mismatch": 0, + "integer_points": 10, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 10, + "redrawn": 0, + "reference_isolation": "process", + "subprograms": { + "is_inf": { + "bit_exact": 10, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 10, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 10, + "redrawn": 0, + "reshaped": 0, + "shaped": 0 + } + }, + "trials": 10, + "ungated": { + "print_msg": "character argument msg: no generated draw for one" + } + }, + "passed": true + }, + "static.rwset": { + "detail": "57 blocks match", + "metrics": { + "blocks_checked": 57, + "blocks_deferred": 3, + "blocks_matched": 57, + "blocks_waived": 0, + "reads_excused_by_stubs": 0 + }, + "passed": true + }, + "symbolic.notary": { + "detail": "no rewrites to notarize; the translation is print-order faithful", + "metrics": { + "rewrites": 0 + }, + "passed": true + } + } + }, + "fortran:csplines": { + "deferred": 10, + "imports": true, + "mechanical": false, + "parses": true, + "stages": { + "frontend/fortran": "ok", + "oracle/f2py-golden": "failed", + "store/fs-evidence": "ok", + "transform/translate.numpy": "ok", + "verifier/static.rwset": "ok" + }, + "stopped_by": "f2py-golden", + "verdicts": { + "static.rwset": { + "detail": "67 blocks match", + "metrics": { + "blocks_checked": 67, + "blocks_deferred": 10, + "blocks_matched": 67, + "blocks_waived": 0, + "reads_excused_by_stubs": 0 + }, + "passed": true + } + } + }, + "fortran:exponential": { + "deferred": 1, + "imports": true, + "mechanical": false, + "parses": true, + "stages": { + "frontend/fortran": "ok", + "oracle/f2py-golden": "failed", + "store/fs-evidence": "ok", + "transform/translate.numpy": "ok", + "verifier/static.rwset": "ok" + }, + "stopped_by": "f2py-golden", + "verdicts": { + "static.rwset": { + "detail": "6 blocks match", + "metrics": { + "blocks_checked": 6, + "blocks_deferred": 1, + "blocks_matched": 6, + "blocks_waived": 0, + "reads_excused_by_stubs": 0 + }, + "passed": true + } + } + }, + "fortran:fitpack": { + "deferred": 8, + "imports": true, + "mechanical": false, + "parses": true, + "stages": { + "frontend/fortran": "ok", + "oracle/f2py-golden": "failed", + "store/fs-evidence": "ok", + "transform/translate.numpy": "ok", + "verifier/static.rwset": "ok" + }, + "stopped_by": "f2py-golden", + "verdicts": { + "static.rwset": { + "detail": "124 blocks match", + "metrics": { + "blocks_checked": 124, + "blocks_deferred": 8, + "blocks_matched": 124, + "blocks_waived": 0, + "reads_excused_by_stubs": 0 + }, + "passed": true + } + } + }, + "fortran:fstring": { + "deferred": 3, + "imports": true, + "mechanical": false, + "parses": true, + "stages": { + "frontend/fortran": "ok", + "oracle/f2py-golden": "failed", + "store/fs-evidence": "ok", + "transform/translate.numpy": "ok", + "verifier/static.rwset": "ok" + }, + "stopped_by": "f2py-golden", + "verdicts": { + "static.rwset": { + "detail": "28 blocks match", + "metrics": { + "blocks_checked": 28, + "blocks_deferred": 3, + "blocks_matched": 28, + "blocks_waived": 0, + "reads_excused_by_stubs": 0 + }, + "passed": true + } + } + }, + "fortran:func_integ": { + "deferred": 0, + "imports": true, + "mechanical": true, + "parses": true, + "stages": { + "frontend/fortran": "ok", + "oracle/f2py-golden": "failed", + "store/fs-evidence": "ok", + "transform/translate.numpy": "ok", + "verifier/static.rwset": "ok" + }, + "stopped_by": "f2py-golden", + "verdicts": { + "static.rwset": { + "detail": "0 blocks match", + "metrics": { + "blocks_checked": 0, + "blocks_deferred": 0, + "blocks_matched": 0, + "blocks_waived": 0, + "reads_excused_by_stubs": 0 + }, + "passed": true + } + } + }, + "fortran:gauss": { + "deferred": 2, + "imports": true, + "mechanical": false, + "parses": true, + "stages": { + "frontend/fortran": "ok", + "oracle/f2py-golden": "failed", + "store/fs-evidence": "ok", + "transform/translate.numpy": "ok", + "verifier/static.rwset": "ok" + }, + "stopped_by": "f2py-golden", + "verdicts": { + "static.rwset": { + "detail": "14 blocks match", + "metrics": { + "blocks_checked": 14, + "blocks_deferred": 2, + "blocks_matched": 14, + "blocks_waived": 0, + "reads_excused_by_stubs": 0 + }, + "passed": true + } + } + }, + "fortran:grids": { + "deferred": 2, + "imports": true, + "mechanical": false, + "parses": true, + "stages": { + "frontend/fortran": "ok", + "oracle/f2py-golden": "ok", + "store/fs-evidence": "ok", + "transform/translate.numpy": "ok", + "verifier/differential.bitexact": "failed", + "verifier/static.rwset": "ok" + }, + "stopped_by": "differential.bitexact", + "verdicts": { + "differential.bitexact": { + "detail": "3 subprogram(s) could not be compared: logspace: candidate raised: NotImplementedError: do with a loop control that is neither a count nor a condition; geomspac", + "metrics": { + "bit_exact": 0, + "declined": {}, + "input_profile": null, "integer_mismatch": 0, - "integer_points": 10, + "integer_points": 0, "max_rel": 0.0, "max_ulp": 0, "nan_mismatch": 0, - "points": 10, + "points": 0, "redrawn": 0, "reference_isolation": "process", "subprograms": { - "is_inf": { - "bit_exact": 10, - "declined": {}, - "integer_mismatch": 0, - "integer_points": 10, - "max_rel": 0.0, - "max_ulp": 0, - "nan_mismatch": 0, - "points": 10, - "redrawn": 0, - "reshaped": 0, - "shaped": 0 + "geomspace": { + "error": "candidate raised: NotImplementedError: do with a loop control that is neither a count nor a condition" + }, + "loglinspace": { + "error": "18 draw(s) were declined (no reason recorded) 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" + }, + "logspace": { + "error": "candidate raised: NotImplementedError: do with a loop control that is neither a count nor a condition" } }, "trials": 10, "ungated": { - "print_msg": "character argument msg: no generated draw for one" + "arange": "array-valued result with a deferred/assumed extent is not wrappable; wrap it by hand or drop the subprogram from the gate" } }, - "passed": true + "passed": false }, "static.rwset": { - "detail": "57 blocks match", + "detail": "41 blocks match", "metrics": { - "blocks_checked": 57, - "blocks_deferred": 3, - "blocks_matched": 57, + "blocks_checked": 41, + "blocks_deferred": 2, + "blocks_matched": 41, "blocks_waived": 0, "reads_excused_by_stubs": 0 }, "passed": true - }, - "symbolic.notary": { - "detail": "no rewrites to notarize; the translation is print-order faithful", + } + } + }, + "fortran:histograms": { + "deferred": 1, + "imports": true, + "mechanical": false, + "parses": true, + "stages": { + "frontend/fortran": "ok", + "oracle/f2py-golden": "failed", + "store/fs-evidence": "ok", + "transform/translate.numpy": "ok", + "verifier/static.rwset": "ok" + }, + "stopped_by": "f2py-golden", + "verdicts": { + "static.rwset": { + "detail": "34 blocks match", "metrics": { - "rewrites": 0 + "blocks_checked": 34, + "blocks_deferred": 1, + "blocks_matched": 34, + "blocks_waived": 0, + "reads_excused_by_stubs": 0 }, "passed": true } } }, - "fortran:csplines": { - "deferred": 13, + "fortran:integrate": { + "deferred": 0, "imports": true, - "mechanical": false, + "mechanical": true, "parses": true, "stages": { "frontend/fortran": "ok", + "oracle/f2py-golden": "failed", "store/fs-evidence": "ok", "transform/translate.numpy": "ok", - "verifier/static.rwset": "failed" + "verifier/static.rwset": "ok" }, - "stopped_by": "static.rwset", + "stopped_by": "f2py-golden", "verdicts": { "static.rwset": { - "detail": "5/67 blocks disagree: csplint/B006, csplint/B012, csplint/B016, csplint_square/B011, csplint_square/B015", + "detail": "0 blocks match", "metrics": { - "blocks_checked": 67, - "blocks_deferred": 13, - "blocks_matched": 62, + "blocks_checked": 0, + "blocks_deferred": 0, + "blocks_matched": 0, "blocks_waived": 0, "reads_excused_by_stubs": 0 }, - "passed": false + "passed": true } } }, - "fortran:exponential": { + "fortran:interpolate": { + "deferred": 0, + "imports": true, + "mechanical": true, + "parses": true, + "stages": { + "frontend/fortran": "ok", + "oracle/f2py-golden": "failed", + "store/fs-evidence": "ok", + "transform/translate.numpy": "ok", + "verifier/static.rwset": "ok" + }, + "stopped_by": "f2py-golden", + "verdicts": { + "static.rwset": { + "detail": "0 blocks match", + "metrics": { + "blocks_checked": 0, + "blocks_deferred": 0, + "blocks_matched": 0, + "blocks_waived": 0, + "reads_excused_by_stubs": 0 + }, + "passed": true + } + } + }, + "fortran:mt19937_64": { "deferred": 1, "imports": true, "mechanical": false, @@ -1601,11 +2263,11 @@ "stopped_by": "f2py-golden", "verdicts": { "static.rwset": { - "detail": "6 blocks match", + "detail": "23 blocks match", "metrics": { - "blocks_checked": 6, + "blocks_checked": 23, "blocks_deferred": 1, - "blocks_matched": 6, + "blocks_matched": 23, "blocks_waived": 0, "reads_excused_by_stubs": 0 }, @@ -1613,34 +2275,234 @@ } } }, - "fortran:fitpack": { - "deferred": 8, + "fortran:mt95": { + "deferred": 3, "imports": true, "mechanical": false, "parses": true, "stages": { "frontend/fortran": "ok", + "oracle/f2py-golden": "ok", "store/fs-evidence": "ok", "transform/translate.numpy": "ok", - "verifier/static.rwset": "failed" + "verifier/differential.bitexact": "failed", + "verifier/static.rwset": "ok" }, - "stopped_by": "static.rwset", + "stopped_by": "differential.bitexact", "verdicts": { + "differential.bitexact": { + "detail": "49 subprogram(s) could not be compared: init_by_type: unsupported declared dtype(s) argument 'put'='UNKNOWN(TYPE(GENRAND_STATE))', argument 'get'='UNKNOWN(TYPE(", + "metrics": { + "bit_exact": 0, + "declined": {}, + "input_profile": null, + "integer_mismatch": 90, + "integer_points": 90, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 90, + "redrawn": 0, + "reference_isolation": "process", + "subprograms": { + "genrand_int31_0d": { + "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + }, + "genrand_int31_1d": { + "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + }, + "genrand_int31_2d": { + "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + }, + "genrand_int31_3d": { + "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + }, + "genrand_int31_4d": { + "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + }, + "genrand_int31_5d": { + "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + }, + "genrand_int31_6d": { + "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + }, + "genrand_int31_7d": { + "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + }, + "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 + }, + "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 + }, + "genrand_int32_2d": { + "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + }, + "genrand_int32_3d": { + "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + }, + "genrand_int32_4d": { + "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + }, + "genrand_int32_5d": { + "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + }, + "genrand_int32_6d": { + "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + }, + "genrand_int32_7d": { + "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + }, + "genrand_real1_0d": { + "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + }, + "genrand_real1_1d": { + "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + }, + "genrand_real1_2d": { + "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + }, + "genrand_real1_3d": { + "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + }, + "genrand_real1_4d": { + "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + }, + "genrand_real1_5d": { + "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + }, + "genrand_real1_6d": { + "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + }, + "genrand_real1_7d": { + "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + }, + "genrand_real2_0d": { + "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + }, + "genrand_real2_1d": { + "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + }, + "genrand_real2_2d": { + "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + }, + "genrand_real2_3d": { + "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + }, + "genrand_real2_4d": { + "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + }, + "genrand_real2_5d": { + "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + }, + "genrand_real2_6d": { + "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + }, + "genrand_real2_7d": { + "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + }, + "genrand_real3_0d": { + "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + }, + "genrand_real3_1d": { + "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + }, + "genrand_real3_2d": { + "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + }, + "genrand_real3_3d": { + "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + }, + "genrand_real3_4d": { + "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + }, + "genrand_real3_5d": { + "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + }, + "genrand_real3_6d": { + "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + }, + "genrand_real3_7d": { + "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + }, + "genrand_res53_0d": { + "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + }, + "genrand_res53_1d": { + "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + }, + "genrand_res53_2d": { + "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + }, + "genrand_res53_3d": { + "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + }, + "genrand_res53_4d": { + "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + }, + "genrand_res53_5d": { + "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + }, + "genrand_res53_6d": { + "error": "candidate raised: NotImplementedError: intrinsic subroutine 'mvbits' has no rule" + }, + "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" + }, + "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" + } + }, + "trials": 10, + "ungated": { + "genrand_decode": "character argument chr: no generated draw for one", + "genrand_dump_state": "argument 'rpr' has dtype 'UNKNOWN(TYPE(GENRAND_SREPR))', which this wrapper cannot spell (component genrand_srepr%repr has dtype 'str'); wrap it by hand or drop the subprogram from the gate", + "genrand_load_state": "argument 'stt' has dtype 'UNKNOWN(TYPE(GENRAND_STATE))', which this wrapper cannot spell (component genrand_state%val is not a scalar); wrap it by hand or drop the subprogram from the gate" + } + }, + "passed": false + }, "static.rwset": { - "detail": "1/124 blocks disagree: splrep_msg/B002", + "detail": "187 blocks match", "metrics": { - "blocks_checked": 124, - "blocks_deferred": 8, - "blocks_matched": 123, + "blocks_checked": 187, + "blocks_deferred": 3, + "blocks_matched": 187, "blocks_waived": 0, "reads_excused_by_stubs": 0 }, - "passed": false + "passed": true } } }, - "fortran:fstring": { - "deferred": 3, + "fortran:mtrandom": { + "deferred": 10, "imports": true, "mechanical": false, "parses": true, @@ -1654,11 +2516,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": 10, + "blocks_matched": 26, "blocks_waived": 0, "reads_excused_by_stubs": 0 }, @@ -1666,7 +2528,7 @@ } } }, - "fortran:func_integ": { + "fortran:numfor": { "deferred": 0, "imports": true, "mechanical": true, @@ -1693,37 +2555,109 @@ } } }, - "fortran:gauss": { - "deferred": 2, + "fortran:polynomial": { + "deferred": 0, "imports": true, - "mechanical": false, + "mechanical": true, "parses": true, "stages": { "frontend/fortran": "ok", - "oracle/f2py-golden": "failed", + "oracle/f2py-golden": "ok", "store/fs-evidence": "ok", "transform/translate.numpy": "ok", - "verifier/static.rwset": "ok" + "verifier/differential.bitexact": "ok", + "verifier/static.rwset": "ok", + "verifier/symbolic.notary": "ok" }, - "stopped_by": "f2py-golden", + "stopped_by": null, "verdicts": { + "differential.bitexact": { + "detail": "110 points across 3 subprogram(s), all bit-exact; 2 draw(s) declined and drawn again (no reason recorded); 2 subprogram(s) ungated, no reference: polyder (array", + "metrics": { + "bit_exact": 110, + "declined": {}, + "input_profile": null, + "integer_mismatch": 0, + "integer_points": 0, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 110, + "redrawn": 2, + "reference_isolation": "process", + "subprograms": { + "bisect_pol": { + "bit_exact": 20, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 0, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 20, + "redrawn": 2, + "reshaped": 0, + "shaped": 0 + }, + "polyval_1": { + "bit_exact": 10, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 0, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 10, + "redrawn": 0, + "reshaped": 0, + "shaped": 0 + }, + "polyval_v": { + "bit_exact": 80, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 0, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 80, + "redrawn": 0, + "reshaped": 0, + "shaped": 0 + } + }, + "trials": 10, + "ungated": { + "polyder": "array-valued result with a deferred/assumed extent is not wrappable; wrap it by hand or drop the subprogram from the gate", + "polyint": "array-valued result with a deferred/assumed extent is not wrappable; wrap it by hand or drop the subprogram from the gate" + } + }, + "passed": true + }, "static.rwset": { - "detail": "14 blocks match", + "detail": "30 blocks match", "metrics": { - "blocks_checked": 14, - "blocks_deferred": 2, - "blocks_matched": 14, + "blocks_checked": 30, + "blocks_deferred": 0, + "blocks_matched": 30, "blocks_waived": 0, "reads_excused_by_stubs": 0 }, "passed": true + }, + "symbolic.notary": { + "detail": "no rewrites to notarize; the translation is print-order faithful", + "metrics": { + "rewrites": 0 + }, + "passed": true } } }, - "fortran:grids": { - "deferred": 2, + "fortran:qadaptive": { + "deferred": 0, "imports": true, - "mechanical": false, + "mechanical": true, "parses": true, "stages": { "frontend/fortran": "ok", @@ -1735,11 +2669,11 @@ "stopped_by": "f2py-golden", "verdicts": { "static.rwset": { - "detail": "41 blocks match", + "detail": "0 blocks match", "metrics": { - "blocks_checked": 41, - "blocks_deferred": 2, - "blocks_matched": 41, + "blocks_checked": 0, + "blocks_deferred": 0, + "blocks_matched": 0, "blocks_waived": 0, "reads_excused_by_stubs": 0 }, @@ -1747,33 +2681,34 @@ } } }, - "fortran:histograms": { - "deferred": 1, + "fortran:qsimpson": { + "deferred": 0, "imports": true, - "mechanical": false, + "mechanical": true, "parses": true, "stages": { "frontend/fortran": "ok", + "oracle/f2py-golden": "failed", "store/fs-evidence": "ok", "transform/translate.numpy": "ok", - "verifier/static.rwset": "failed" + "verifier/static.rwset": "ok" }, - "stopped_by": "static.rwset", + "stopped_by": "f2py-golden", "verdicts": { "static.rwset": { - "detail": "2/34 blocks disagree: histogram/B005, get_bins/B001", + "detail": "2 blocks match", "metrics": { - "blocks_checked": 34, - "blocks_deferred": 1, - "blocks_matched": 32, + "blocks_checked": 2, + "blocks_deferred": 0, + "blocks_matched": 2, "blocks_waived": 0, "reads_excused_by_stubs": 0 }, - "passed": false + "passed": true } } }, - "fortran:integrate": { + "fortran:qtanhsinh": { "deferred": 0, "imports": true, "mechanical": true, @@ -1800,7 +2735,7 @@ } } }, - "fortran:interpolate": { + "fortran:quadpack": { "deferred": 0, "imports": true, "mechanical": true, @@ -1815,11 +2750,11 @@ "stopped_by": "f2py-golden", "verdicts": { "static.rwset": { - "detail": "0 blocks match", + "detail": "9 blocks match", "metrics": { - "blocks_checked": 0, + "blocks_checked": 9, "blocks_deferred": 0, - "blocks_matched": 0, + "blocks_matched": 9, "blocks_waived": 0, "reads_excused_by_stubs": 0 }, @@ -1827,10 +2762,10 @@ } } }, - "fortran:mt19937_64": { - "deferred": 1, + "fortran:randist": { + "deferred": 0, "imports": true, - "mechanical": false, + "mechanical": true, "parses": true, "stages": { "frontend/fortran": "ok", @@ -1842,11 +2777,11 @@ "stopped_by": "f2py-golden", "verdicts": { "static.rwset": { - "detail": "23 blocks match", + "detail": "0 blocks match", "metrics": { - "blocks_checked": 23, - "blocks_deferred": 1, - "blocks_matched": 23, + "blocks_checked": 0, + "blocks_deferred": 0, + "blocks_matched": 0, "blocks_waived": 0, "reads_excused_by_stubs": 0 }, @@ -1854,140 +2789,181 @@ } } }, - "fortran:mt95": { - "deferred": 3, + "fortran:random": { + "deferred": 4, "imports": true, "mechanical": false, "parses": true, "stages": { "frontend/fortran": "ok", + "oracle/f2py-golden": "failed", "store/fs-evidence": "ok", "transform/translate.numpy": "ok", - "verifier/static.rwset": "failed" + "verifier/static.rwset": "ok" }, - "stopped_by": "static.rwset", + "stopped_by": "f2py-golden", "verdicts": { "static.rwset": { - "detail": "3/187 blocks disagree: genrand_encode/B002, genrand_encode/B003, genrand_decode/B002", + "detail": "12 blocks match", "metrics": { - "blocks_checked": 187, - "blocks_deferred": 3, - "blocks_matched": 184, + "blocks_checked": 12, + "blocks_deferred": 4, + "blocks_matched": 12, "blocks_waived": 0, "reads_excused_by_stubs": 0 }, - "passed": false + "passed": true } } }, - "fortran:mtrandom": { - "deferred": 4, + "fortran:sorting": { + "deferred": 0, "imports": true, - "mechanical": false, + "mechanical": true, "parses": true, "stages": { "frontend/fortran": "ok", + "oracle/f2py-golden": "ok", "store/fs-evidence": "ok", "transform/translate.numpy": "ok", - "verifier/static.rwset": "failed" + "verifier/differential.bitexact": "ok", + "verifier/static.rwset": "ok", + "verifier/symbolic.notary": "ok" }, - "stopped_by": "static.rwset", + "stopped_by": null, "verdicts": { - "static.rwset": { - "detail": "1/32 blocks disagree: read_urandom/B001", + "differential.bitexact": { + "detail": "120 points across 5 subprogram(s), all bit-exact", "metrics": { - "blocks_checked": 32, - "blocks_deferred": 4, - "blocks_matched": 31, - "blocks_waived": 0, - "reads_excused_by_stubs": 0 + "bit_exact": 120, + "declined": {}, + "input_profile": null, + "integer_mismatch": 0, + "integer_points": 40, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 120, + "redrawn": 0, + "reference_isolation": "process", + "subprograms": { + "searchsorted_dp": { + "bit_exact": 10, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 10, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 10, + "redrawn": 0, + "reshaped": 0, + "shaped": 0 + }, + "searchsorted_dpi": { + "bit_exact": 10, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 10, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 10, + "redrawn": 0, + "reshaped": 0, + "shaped": 0 + }, + "searchsorted_i": { + "bit_exact": 10, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 10, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 10, + "redrawn": 0, + "reshaped": 0, + "shaped": 0 + }, + "searchsorted_idp": { + "bit_exact": 10, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 10, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 10, + "redrawn": 0, + "reshaped": 0, + "shaped": 0 + }, + "sort": { + "bit_exact": 80, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 0, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 80, + "redrawn": 0, + "reshaped": 0, + "shaped": 0 + } + }, + "trials": 10 }, - "passed": false - } - } - }, - "fortran:numfor": { - "deferred": 0, - "imports": true, - "mechanical": true, - "parses": true, - "stages": { - "frontend/fortran": "ok", - "oracle/f2py-golden": "failed", - "store/fs-evidence": "ok", - "transform/translate.numpy": "ok", - "verifier/static.rwset": "ok" - }, - "stopped_by": "f2py-golden", - "verdicts": { + "passed": true + }, "static.rwset": { - "detail": "0 blocks match", + "detail": "62 blocks match", "metrics": { - "blocks_checked": 0, + "blocks_checked": 62, "blocks_deferred": 0, - "blocks_matched": 0, + "blocks_matched": 62, "blocks_waived": 0, "reads_excused_by_stubs": 0 }, "passed": true - } - } - }, - "fortran:polynomial": { - "deferred": 0, - "imports": true, - "mechanical": true, - "parses": true, - "stages": { - "frontend/fortran": "ok", - "oracle/f2py-golden": "failed", - "store/fs-evidence": "ok", - "transform/translate.numpy": "ok", - "verifier/static.rwset": "ok" - }, - "stopped_by": "f2py-golden", - "verdicts": { - "static.rwset": { - "detail": "30 blocks match", + }, + "symbolic.notary": { + "detail": "no rewrites to notarize; the translation is print-order faithful", "metrics": { - "blocks_checked": 30, - "blocks_deferred": 0, - "blocks_matched": 30, - "blocks_waived": 0, - "reads_excused_by_stubs": 0 + "rewrites": 0 }, "passed": true } } }, - "fortran:qadaptive": { - "deferred": 0, + "fortran:strings": { + "deferred": 2, "imports": true, - "mechanical": true, + "mechanical": false, "parses": true, "stages": { "frontend/fortran": "ok", - "oracle/f2py-golden": "failed", "store/fs-evidence": "ok", "transform/translate.numpy": "ok", - "verifier/static.rwset": "ok" + "verifier/static.rwset": "failed" }, - "stopped_by": "f2py-golden", + "stopped_by": "static.rwset", "verdicts": { "static.rwset": { - "detail": "0 blocks match", + "detail": "5/107 blocks disagree: replace/B006, zarr2str/B002, dparr2str/B002, rarr2str/B002, iarr2str/B002", "metrics": { - "blocks_checked": 0, - "blocks_deferred": 0, - "blocks_matched": 0, + "blocks_checked": 107, + "blocks_deferred": 2, + "blocks_matched": 102, "blocks_waived": 0, "reads_excused_by_stubs": 0 }, - "passed": true + "passed": false } } }, - "fortran:qsimpson": { + "fortran:uniform": { "deferred": 0, "imports": true, "mechanical": true, @@ -2002,11 +2978,11 @@ "stopped_by": "f2py-golden", "verdicts": { "static.rwset": { - "detail": "2 blocks match", + "detail": "0 blocks match", "metrics": { - "blocks_checked": 2, + "blocks_checked": 0, "blocks_deferred": 0, - "blocks_matched": 2, + "blocks_matched": 0, "blocks_waived": 0, "reads_excused_by_stubs": 0 }, @@ -2014,7 +2990,7 @@ } } }, - "fortran:qtanhsinh": { + "fortran:utils": { "deferred": 0, "imports": true, "mechanical": true, @@ -2040,63 +3016,224 @@ "passed": true } } - }, - "fortran:quadpack": { + } + } + }, + "pchip": { + "bare_files": 0, + "blocks": 158, + "deferred": 0, + "refusals": [], + "status": "passed", + "units": { + "fortran:pchip_module": { "deferred": 0, "imports": true, "mechanical": true, "parses": true, "stages": { "frontend/fortran": "ok", - "oracle/f2py-golden": "failed", + "oracle/f2py-golden": "ok", "store/fs-evidence": "ok", "transform/translate.numpy": "ok", - "verifier/static.rwset": "ok" + "verifier/differential.bitexact": "ok", + "verifier/static.rwset": "ok", + "verifier/symbolic.notary": "ok" }, - "stopped_by": "f2py-golden", + "stopped_by": null, "verdicts": { + "differential.bitexact": { + "detail": "3070 points across 9 subprogram(s), all bit-exact; 6 draw(s) declined and drawn again (1 reference subscript out of bounds, 5 subscript past extent); 3 subprogr", + "metrics": { + "bit_exact": 3070, + "declined": { + "reference subscript out of bounds": 1, + "subscript past extent": 5 + }, + "input_profile": null, + "integer_mismatch": 0, + "integer_points": 240, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 3070, + "redrawn": 6, + "reference_isolation": "process", + "subprograms": { + "dchfdv": { + "bit_exact": 190, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 30, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 190, + "redrawn": 0, + "reshaped": 0, + "shaped": 0 + }, + "dchfev": { + "bit_exact": 110, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 30, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 110, + "redrawn": 0, + "reshaped": 0, + "shaped": 0 + }, + "dpchbs": { + "bit_exact": 200, + "declined": { + "reference subscript out of bounds": 1, + "subscript past extent": 5 + }, + "integer_mismatch": 0, + "integer_points": 40, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 200, + "redrawn": 6, + "reshaped": 5, + "shaped": 0 + }, + "dpchcm": { + "bit_exact": 100, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 90, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 100, + "redrawn": 0, + "reshaped": 0, + "shaped": 0 + }, + "dpchfd": { + "bit_exact": 180, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 10, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 180, + "redrawn": 0, + "reshaped": 0, + "shaped": 0 + }, + "dpchfe": { + "bit_exact": 100, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 10, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 100, + "redrawn": 0, + "reshaped": 0, + "shaped": 0 + }, + "dpchic": { + "bit_exact": 730, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 10, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 730, + "redrawn": 0, + "reshaped": 0, + "shaped": 0 + }, + "dpchim": { + "bit_exact": 650, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 10, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 650, + "redrawn": 0, + "reshaped": 0, + "shaped": 0 + }, + "dpchsp": { + "bit_exact": 810, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 10, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 810, + "redrawn": 0, + "reshaped": 0, + "shaped": 0 + } + }, + "trials": 10, + "ungated": { + "dpchia": "declares OUT/INOUT dummy argument(s) skip, ierr; this verifier cannot pair both its result and side effects", + "dpchid": "declares OUT/INOUT dummy argument(s) skip, ierr; this verifier cannot pair both its result and side effects", + "xermsg": "character argument librar: no generated draw for one" + } + }, + "passed": true + }, "static.rwset": { - "detail": "9 blocks match", + "detail": "158 blocks match", "metrics": { - "blocks_checked": 9, + "blocks_checked": 158, "blocks_deferred": 0, - "blocks_matched": 9, + "blocks_matched": 158, "blocks_waived": 0, "reads_excused_by_stubs": 0 }, "passed": true - } - } - }, - "fortran:randist": { - "deferred": 0, - "imports": true, - "mechanical": true, - "parses": true, - "stages": { - "frontend/fortran": "ok", - "oracle/f2py-golden": "failed", - "store/fs-evidence": "ok", - "transform/translate.numpy": "ok", - "verifier/static.rwset": "ok" - }, - "stopped_by": "f2py-golden", - "verdicts": { - "static.rwset": { - "detail": "0 blocks match", + }, + "symbolic.notary": { + "detail": "no rewrites to notarize; the translation is print-order faithful", "metrics": { - "blocks_checked": 0, - "blocks_deferred": 0, - "blocks_matched": 0, - "blocks_waived": 0, - "reads_excused_by_stubs": 0 + "rewrites": 0 }, "passed": true } } - }, - "fortran:random": { - "deferred": 4, + } + } + }, + "polyroots": { + "bare_files": 0, + "blocks": 418, + "deferred": 22, + "refusals": [ + [ + "cmplx(x, y): the two-part form is not spelled; cmplx(x, kind=k) is", + 20 + ], + [ + "cmplx_roots_gen/P001: local parameter zero (cmplx(x, y): the two-part form is not spelled;", + 1 + ], + [ + "cmplx_roots_gen/P002: local parameter c_one (cmplx(x, y): the two-part form is not spelled", + 1 + ] + ], + "status": "failed", + "units": { + "fortran:polyroots_module": { + "deferred": 22, "imports": true, "mechanical": false, "parses": true, @@ -2110,19 +3247,28 @@ "stopped_by": "f2py-golden", "verdicts": { "static.rwset": { - "detail": "12 blocks match", + "detail": "418 blocks match", "metrics": { - "blocks_checked": 12, - "blocks_deferred": 4, - "blocks_matched": 12, + "blocks_checked": 418, + "blocks_deferred": 22, + "blocks_matched": 418, "blocks_waived": 0, "reads_excused_by_stubs": 0 }, "passed": true } } - }, - "fortran:sorting": { + } + } + }, + "quadpack": { + "bare_files": 0, + "blocks": 628, + "deferred": 0, + "refusals": [], + "status": "failed", + "units": { + "fortran:quadpack_double": { "deferred": 0, "imports": true, "mechanical": true, @@ -2132,361 +3278,484 @@ "oracle/f2py-golden": "ok", "store/fs-evidence": "ok", "transform/translate.numpy": "ok", - "verifier/differential.bitexact": "ok", - "verifier/static.rwset": "ok", - "verifier/symbolic.notary": "ok" + "verifier/differential.bitexact": "failed", + "verifier/static.rwset": "ok" }, - "stopped_by": null, + "stopped_by": "differential.bitexact", "verdicts": { "differential.bitexact": { - "detail": "120 points across 5 subprogram(s), all bit-exact", + "detail": "1 subprogram(s) could not be compared: dqc25s: no draw this harness could compare in 24 attempt(s) (45 NaN on both sides): resasc: a NaN in the compared values ", "metrics": { - "bit_exact": 120, - "declined": {}, + "bit_exact": 14170, + "declined": { + "NaN on both sides": 1 + }, "input_profile": null, "integer_mismatch": 0, - "integer_points": 40, + "integer_points": 2310, "max_rel": 0.0, "max_ulp": 0, "nan_mismatch": 0, - "points": 120, - "redrawn": 0, - "reference_isolation": "process", + "points": 14170, + "redrawn": 1, + "reference_isolation": "in-process (call-back arguments: dgauss8, dlobatto, dqag, dqage, dqagi, dqagie, dqagp, dqagpe, dqags, dqagse, dqawc, dqawce, dqawf, dqawfe, dqawo, dqawoe, dqaws, dqawse, dqc25c, dqc25f, dqc25s, dqk15, dqk15i, dqk15w, dqk21, dqk31, dqk41, dqk51, dqk61, dqnc79, dqng, dquad, dsimpson)", "subprograms": { - "searchsorted_dp": { - "bit_exact": 10, + "davint": { + "bit_exact": 20, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 10, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 20, + "redrawn": 0, + "reshaped": 0, + "shaped": 0 + }, + "dgauss8": { + "bit_exact": 30, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 10, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 30, + "redrawn": 0, + "reshaped": 0, + "shaped": 0 + }, + "dlobatto": { + "bit_exact": 20, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 10, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 20, + "redrawn": 0, + "reshaped": 0, + "shaped": 0 + }, + "dqag": { + "bit_exact": 210, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 110, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 210, + "redrawn": 0, + "reshaped": 0, + "shaped": 0 + }, + "dqage": { + "bit_exact": 450, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 110, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 450, + "redrawn": 0, + "reshaped": 0, + "shaped": 0 + }, + "dqagi": { + "bit_exact": 210, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 110, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 210, + "redrawn": 0, + "reshaped": 0, + "shaped": 0 + }, + "dqagie": { + "bit_exact": 450, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 110, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 450, + "redrawn": 0, + "reshaped": 0, + "shaped": 0 + }, + "dqagp": { + "bit_exact": 210, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 110, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 210, + "redrawn": 0, + "reshaped": 0, + "shaped": 0 + }, + "dqagpe": { + "bit_exact": 690, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 270, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 690, + "redrawn": 0, + "reshaped": 0, + "shaped": 0 + }, + "dqags": { + "bit_exact": 210, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 110, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 210, + "redrawn": 0, + "reshaped": 0, + "shaped": 0 + }, + "dqagse": { + "bit_exact": 450, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 110, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 450, + "redrawn": 0, + "reshaped": 0, + "shaped": 0 + }, + "dqawc": { + "bit_exact": 210, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 110, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 210, + "redrawn": 0, + "reshaped": 0, + "shaped": 0 + }, + "dqawce": { + "bit_exact": 450, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 110, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 450, + "redrawn": 0, + "reshaped": 0, + "shaped": 0 + }, + "dqawf": { + "bit_exact": 210, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 110, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 210, + "redrawn": 0, + "reshaped": 0, + "shaped": 0 + }, + "dqawfe": { + "bit_exact": 2770, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 270, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 2770, + "redrawn": 0, + "reshaped": 0, + "shaped": 0 + }, + "dqawo": { + "bit_exact": 210, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 110, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 210, + "redrawn": 0, + "reshaped": 0, + "shaped": 0 + }, + "dqawoe": { + "bit_exact": 2540, "declined": {}, "integer_mismatch": 0, - "integer_points": 10, + "integer_points": 200, "max_rel": 0.0, "max_ulp": 0, "nan_mismatch": 0, - "points": 10, + "points": 2540, "redrawn": 0, "reshaped": 0, "shaped": 0 }, - "searchsorted_dpi": { - "bit_exact": 10, + "dqaws": { + "bit_exact": 210, "declined": {}, "integer_mismatch": 0, - "integer_points": 10, + "integer_points": 110, "max_rel": 0.0, "max_ulp": 0, "nan_mismatch": 0, - "points": 10, + "points": 210, "redrawn": 0, "reshaped": 0, "shaped": 0 }, - "searchsorted_i": { - "bit_exact": 10, + "dqawse": { + "bit_exact": 450, + "declined": { + "NaN on both sides": 1 + }, + "integer_mismatch": 0, + "integer_points": 110, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 450, + "redrawn": 1, + "reshaped": 0, + "shaped": 0 + }, + "dqc25c": { + "bit_exact": 40, "declined": {}, "integer_mismatch": 0, - "integer_points": 10, + "integer_points": 20, "max_rel": 0.0, "max_ulp": 0, "nan_mismatch": 0, - "points": 10, + "points": 40, "redrawn": 0, "reshaped": 0, "shaped": 0 }, - "searchsorted_idp": { - "bit_exact": 10, + "dqc25f": { + "bit_exact": 2060, "declined": {}, "integer_mismatch": 0, - "integer_points": 10, + "integer_points": 20, "max_rel": 0.0, "max_ulp": 0, "nan_mismatch": 0, - "points": 10, + "points": 2060, "redrawn": 0, "reshaped": 0, "shaped": 0 }, - "sort": { - "bit_exact": 80, + "dqc25s": { + "error": "no draw this harness could compare in 24 attempt(s) (45 NaN on both sides): resasc: a NaN in the compared values on both sides" + }, + "dqcheb": { + "bit_exact": 630, "declined": {}, "integer_mismatch": 0, "integer_points": 0, "max_rel": 0.0, "max_ulp": 0, "nan_mismatch": 0, - "points": 80, + "points": 630, "redrawn": 0, "reshaped": 0, "shaped": 0 - } - }, - "trials": 10 - }, - "passed": true - }, - "static.rwset": { - "detail": "62 blocks match", - "metrics": { - "blocks_checked": 62, - "blocks_deferred": 0, - "blocks_matched": 62, - "blocks_waived": 0, - "reads_excused_by_stubs": 0 - }, - "passed": true - }, - "symbolic.notary": { - "detail": "no rewrites to notarize; the translation is print-order faithful", - "metrics": { - "rewrites": 0 - }, - "passed": true - } - } - }, - "fortran:strings": { - "deferred": 9, - "imports": true, - "mechanical": false, - "parses": true, - "stages": { - "frontend/fortran": "ok", - "store/fs-evidence": "ok", - "transform/translate.numpy": "ok", - "verifier/static.rwset": "failed" - }, - "stopped_by": "static.rwset", - "verdicts": { - "static.rwset": { - "detail": "5/100 blocks disagree: replace/B006, zarr2str/B002, dparr2str/B002, rarr2str/B002, iarr2str/B002", - "metrics": { - "blocks_checked": 100, - "blocks_deferred": 9, - "blocks_matched": 95, - "blocks_waived": 0, - "reads_excused_by_stubs": 0 - }, - "passed": false - } - } - }, - "fortran:uniform": { - "deferred": 0, - "imports": true, - "mechanical": true, - "parses": true, - "stages": { - "frontend/fortran": "ok", - "oracle/f2py-golden": "failed", - "store/fs-evidence": "ok", - "transform/translate.numpy": "ok", - "verifier/static.rwset": "ok" - }, - "stopped_by": "f2py-golden", - "verdicts": { - "static.rwset": { - "detail": "0 blocks match", - "metrics": { - "blocks_checked": 0, - "blocks_deferred": 0, - "blocks_matched": 0, - "blocks_waived": 0, - "reads_excused_by_stubs": 0 - }, - "passed": true - } - } - }, - "fortran:utils": { - "deferred": 0, - "imports": true, - "mechanical": true, - "parses": true, - "stages": { - "frontend/fortran": "ok", - "oracle/f2py-golden": "failed", - "store/fs-evidence": "ok", - "transform/translate.numpy": "ok", - "verifier/static.rwset": "ok" - }, - "stopped_by": "f2py-golden", - "verdicts": { - "static.rwset": { - "detail": "0 blocks match", - "metrics": { - "blocks_checked": 0, - "blocks_deferred": 0, - "blocks_matched": 0, - "blocks_waived": 0, - "reads_excused_by_stubs": 0 - }, - "passed": true - } - } - } - } - }, - "pchip": { - "bare_files": 0, - "blocks": 158, - "deferred": 0, - "refusals": [], - "status": "failed", - "units": { - "fortran:pchip_module": { - "deferred": 0, - "imports": true, - "mechanical": true, - "parses": true, - "stages": { - "frontend/fortran": "ok", - "oracle/f2py-golden": "ok", - "store/fs-evidence": "ok", - "transform/translate.numpy": "ok", - "verifier/differential.bitexact": "failed", - "verifier/static.rwset": "ok" - }, - "stopped_by": "differential.bitexact", - "verdicts": { - "differential.bitexact": { - "detail": "2 subprogram(s) could not be compared: dpchia: function 'dpchia' declares OUT/INOUT dummy argument(s) skip, ierr; this verifier cannot pair both its result and ", - "metrics": { - "bit_exact": 3070, - "declined": { - "reference subscript out of bounds": 1, - "subscript past extent": 5 - }, - "input_profile": null, - "integer_mismatch": 0, - "integer_points": 240, - "max_rel": 0.0, - "max_ulp": 0, - "nan_mismatch": 0, - "points": 3070, - "redrawn": 6, - "reference_isolation": "process", - "subprograms": { - "dchfdv": { - "bit_exact": 190, + }, + "dqk15": { + "bit_exact": 40, "declined": {}, "integer_mismatch": 0, - "integer_points": 30, + "integer_points": 0, "max_rel": 0.0, "max_ulp": 0, "nan_mismatch": 0, - "points": 190, + "points": 40, "redrawn": 0, "reshaped": 0, "shaped": 0 }, - "dchfev": { - "bit_exact": 110, + "dqk15i": { + "bit_exact": 40, "declined": {}, "integer_mismatch": 0, - "integer_points": 30, + "integer_points": 0, "max_rel": 0.0, "max_ulp": 0, "nan_mismatch": 0, - "points": 110, + "points": 40, "redrawn": 0, "reshaped": 0, "shaped": 0 }, - "dpchbs": { - "bit_exact": 200, - "declined": { - "reference subscript out of bounds": 1, - "subscript past extent": 5 - }, + "dqk15w": { + "bit_exact": 40, + "declined": {}, "integer_mismatch": 0, - "integer_points": 40, + "integer_points": 0, "max_rel": 0.0, "max_ulp": 0, "nan_mismatch": 0, - "points": 200, - "redrawn": 6, - "reshaped": 5, + "points": 40, + "redrawn": 0, + "reshaped": 0, "shaped": 0 }, - "dpchcm": { - "bit_exact": 100, + "dqk21": { + "bit_exact": 40, "declined": {}, "integer_mismatch": 0, - "integer_points": 90, + "integer_points": 0, "max_rel": 0.0, "max_ulp": 0, "nan_mismatch": 0, - "points": 100, + "points": 40, "redrawn": 0, "reshaped": 0, "shaped": 0 }, - "dpchfd": { - "bit_exact": 180, + "dqk31": { + "bit_exact": 40, "declined": {}, "integer_mismatch": 0, - "integer_points": 10, + "integer_points": 0, "max_rel": 0.0, "max_ulp": 0, "nan_mismatch": 0, - "points": 180, + "points": 40, "redrawn": 0, "reshaped": 0, "shaped": 0 }, - "dpchfe": { - "bit_exact": 100, + "dqk41": { + "bit_exact": 40, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 0, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 40, + "redrawn": 0, + "reshaped": 0, + "shaped": 0 + }, + "dqk51": { + "bit_exact": 40, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 0, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 40, + "redrawn": 0, + "reshaped": 0, + "shaped": 0 + }, + "dqk61": { + "bit_exact": 40, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 0, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 40, + "redrawn": 0, + "reshaped": 0, + "shaped": 0 + }, + "dqmomo": { + "bit_exact": 1000, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 0, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 1000, + "redrawn": 0, + "reshaped": 0, + "shaped": 0 + }, + "dqnc79": { + "bit_exact": 30, "declined": {}, "integer_mismatch": 0, - "integer_points": 10, + "integer_points": 20, "max_rel": 0.0, "max_ulp": 0, "nan_mismatch": 0, - "points": 100, + "points": 30, "redrawn": 0, "reshaped": 0, "shaped": 0 }, - "dpchia": { - "error": "function 'dpchia' declares OUT/INOUT dummy argument(s) skip, ierr; this verifier cannot pair both its result and side effects" - }, - "dpchic": { - "bit_exact": 730, + "dqng": { + "bit_exact": 40, "declined": {}, "integer_mismatch": 0, - "integer_points": 10, + "integer_points": 20, "max_rel": 0.0, "max_ulp": 0, "nan_mismatch": 0, - "points": 730, + "points": 40, "redrawn": 0, "reshaped": 0, "shaped": 0 }, - "dpchid": { - "error": "function 'dpchid' declares OUT/INOUT dummy argument(s) skip, ierr; this verifier cannot pair both its result and side effects" - }, - "dpchim": { - "bit_exact": 650, + "dquad": { + "bit_exact": 30, "declined": {}, "integer_mismatch": 0, - "integer_points": 10, + "integer_points": 20, "max_rel": 0.0, "max_ulp": 0, "nan_mismatch": 0, - "points": 650, + "points": 30, "redrawn": 0, "reshaped": 0, "shaped": 0 }, - "dpchsp": { - "bit_exact": 810, + "dsimpson": { + "bit_exact": 20, "declined": {}, "integer_mismatch": 0, "integer_points": 10, "max_rel": 0.0, "max_ulp": 0, "nan_mismatch": 0, - "points": 810, + "points": 20, "redrawn": 0, "reshaped": 0, "shaped": 0 @@ -2494,17 +3763,17 @@ }, "trials": 10, "ungated": { - "xermsg": "character argument librar: no generated draw for one" + "xerror": "character argument messg: no generated draw for one" } }, "passed": false }, "static.rwset": { - "detail": "158 blocks match", + "detail": "628 blocks match", "metrics": { - "blocks_checked": 158, + "blocks_checked": 628, "blocks_deferred": 0, - "blocks_matched": 158, + "blocks_matched": 628, "blocks_waived": 0, "reads_excused_by_stubs": 0 }, @@ -2514,146 +3783,6 @@ } } }, - "polyroots": { - "bare_files": 0, - "blocks": 416, - "deferred": 31, - "refusals": [ - [ - "cmplx(x, y): the two-part form is not spelled; cmplx(x, kind=k) is", - 20 - ], - [ - "call to external subroutine 'X'", - 2 - ], - [ - "rpoly/P001: local parameter cosr (literal 'X' was never hoisted, so it has no name to emit", - 1 - ], - [ - "rpoly/P002: local parameter sinr (literal 'X' was never hoisted, so it has no name to emit", - 1 - ], - [ - "cmplx_roots_gen/P001: local parameter zero (cmplx(x, y): the two-part form is not spelled;", - 1 - ], - [ - "cmplx_roots_gen/P002: local parameter c_one (cmplx(x, y): the two-part form is not spelled", - 1 - ], - [ - "cpoly/P001: local parameter cosr (literal 'X' was never hoisted, so it has no name to emit", - 1 - ], - [ - "cpoly/P002: local parameter sinr (literal 'X' was never hoisted, so it has no name to emit", - 1 - ], - [ - "cpoly/P003: local parameter mre (literal 'X' was never hoisted, so it has no name to emit)", - 1 - ], - [ - "cpoly/P004: local parameter cos45 (literal 'X' was never hoisted, so it has no name to emi", - 1 - ], - [ - "rroots_chebyshev_cubic/P001: local parameter small (literal 'X' was never hoisted, so it h", - 1 - ] - ], - "status": "failed", - "units": { - "fortran:polyroots_module": { - "deferred": 31, - "imports": true, - "mechanical": false, - "parses": true, - "stages": { - "frontend/fortran": "ok", - "store/fs-evidence": "ok", - "transform/translate.numpy": "ok", - "verifier/static.rwset": "failed" - }, - "stopped_by": "static.rwset", - "verdicts": { - "static.rwset": { - "detail": "37/416 blocks disagree: rpoly/B011, fxshfr/B007, fxshfr/B008, quadit/B006, rpqr79/B012 (+32 more)", - "metrics": { - "blocks_checked": 416, - "blocks_deferred": 31, - "blocks_matched": 379, - "blocks_waived": 0, - "reads_excused_by_stubs": 0 - }, - "passed": false - } - } - } - } - }, - "quadpack": { - "bare_files": 0, - "blocks": 628, - "deferred": 6, - "refusals": [ - [ - "dqc25c/P001: local parameter x (literal 'X' was never hoisted, so it has no name to emit):", - 1 - ], - [ - "dqc25s/P001: local parameter x (literal 'X' was never hoisted, so it has no name to emit):", - 1 - ], - [ - "dqnc79/P001: local parameter nbits (literal 'X' was never hoisted, so it has no name to em", - 1 - ], - [ - "dqnc79/P002: local parameter nlmx (literal 'X' was never hoisted, so it has no name to emi", - 1 - ], - [ - "dgauss8/P001: local parameter nlmx (literal 'X' was never hoisted, so it has no name to em", - 1 - ], - [ - "dlobatto/P001: local parameter alpha (literal 'X' was never hoisted, so it has no name to ", - 1 - ] - ], - "status": "failed", - "units": { - "fortran:quadpack_double": { - "deferred": 6, - "imports": true, - "mechanical": false, - "parses": true, - "stages": { - "frontend/fortran": "ok", - "store/fs-evidence": "ok", - "transform/translate.numpy": "ok", - "verifier/static.rwset": "failed" - }, - "stopped_by": "static.rwset", - "verdicts": { - "static.rwset": { - "detail": "7/628 blocks disagree: dqnc79/B023, dgauss8/B019, dgauss8/B024, dsimpson/B016, adaptive_simpson_step/B010 (+2 more)", - "metrics": { - "blocks_checked": 628, - "blocks_deferred": 6, - "blocks_matched": 621, - "blocks_waived": 0, - "reads_excused_by_stubs": 0 - }, - "passed": false - } - } - } - } - }, "roots": { "bare_files": 0, "blocks": 234, @@ -2708,26 +3837,9 @@ }, "slsqp": { "bare_files": 0, - "blocks": 186, - "deferred": 8, - "refusals": [ - [ - "seq-assoc: element of a for an assumed-size dummy is only a view when both are rank-N", - 5 - ], - [ - "seq-assoc: element of c for an assumed-size dummy is only a view when both are rank-N", - 1 - ], - [ - "seq-assoc: element of e for an assumed-size dummy is only a view when both are rank-N", - 1 - ], - [ - "linmin/P001: local parameter c (literal 'X' was never hoisted, so it has no name to emit):", - 1 - ] - ], + "blocks": 193, + "deferred": 0, + "refusals": [], "status": "failed", "units": { "fortran:bvls_module": { @@ -2737,48 +3849,147 @@ "parses": true, "stages": { "frontend/fortran": "ok", + "oracle/f2py-golden": "ok", "store/fs-evidence": "ok", "transform/translate.numpy": "ok", - "verifier/static.rwset": "failed" + "verifier/differential.bitexact": "ok", + "verifier/static.rwset": "ok", + "verifier/symbolic.notary": "ok" }, - "stopped_by": "static.rwset", + "stopped_by": null, "verdicts": { + "differential.bitexact": { + "detail": "1970 points across 2 subprogram(s), all bit-exact", + "metrics": { + "bit_exact": 1970, + "declined": {}, + "input_profile": null, + "integer_mismatch": 0, + "integer_points": 110, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 1970, + "redrawn": 0, + "reference_isolation": "process", + "subprograms": { + "bvls": { + "bit_exact": 990, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 100, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 990, + "redrawn": 0, + "reshaped": 0, + "shaped": 0 + }, + "bvls_wrapper": { + "bit_exact": 980, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 10, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 980, + "redrawn": 0, + "reshaped": 0, + "shaped": 0 + } + }, + "trials": 10 + }, + "passed": true + }, "static.rwset": { - "detail": "12/68 blocks disagree: bvls/B001, bvls/B002, bvls/B003, initialize/B019, select_another_coeff_to_solve_for/B002 (+7 more)", + "detail": "68 blocks match", "metrics": { "blocks_checked": 68, "blocks_deferred": 0, - "blocks_matched": 56, + "blocks_matched": 68, "blocks_waived": 0, "reads_excused_by_stubs": 0 }, - "passed": false + "passed": true + }, + "symbolic.notary": { + "detail": "no rewrites to notarize; the translation is print-order faithful", + "metrics": { + "rewrites": 0 + }, + "passed": true } } }, "fortran:slsqp_core": { - "deferred": 8, + "deferred": 0, "imports": true, - "mechanical": false, + "mechanical": true, "parses": true, "stages": { "frontend/fortran": "ok", + "oracle/f2py-golden": "ok", "store/fs-evidence": "ok", "transform/translate.numpy": "ok", - "verifier/static.rwset": "failed" + "verifier/differential.bitexact": "ok", + "verifier/static.rwset": "ok", + "verifier/symbolic.notary": "ok" }, - "stopped_by": "static.rwset", + "stopped_by": null, "verdicts": { + "differential.bitexact": { + "detail": "550 points across 1 subprogram(s), all bit-exact", + "metrics": { + "bit_exact": 550, + "declined": {}, + "input_profile": null, + "integer_mismatch": 0, + "integer_points": 100, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 550, + "redrawn": 0, + "reference_isolation": "process", + "subprograms": { + "slsqp": { + "bit_exact": 550, + "declined": {}, + "integer_mismatch": 0, + "integer_points": 100, + "max_rel": 0.0, + "max_ulp": 0, + "nan_mismatch": 0, + "points": 550, + "redrawn": 0, + "reshaped": 0, + "shaped": 0 + } + }, + "trials": 10 + }, + "passed": true + }, "static.rwset": { - "detail": "7/106 blocks disagree: slsqpb/B002, slsqpb/B003, slsqpb/B006, reset_bfgs_matrix/B002, lsq/B026 (+2 more)", + "detail": "113 blocks match", "metrics": { - "blocks_checked": 106, - "blocks_deferred": 8, - "blocks_matched": 99, + "blocks_checked": 113, + "blocks_deferred": 0, + "blocks_matched": 113, "blocks_waived": 0, "reads_excused_by_stubs": 0 }, - "passed": false + "passed": true + }, + "symbolic.notary": { + "detail": "no rewrites to notarize; the translation is print-order faithful", + "metrics": { + "rewrites": 0 + }, + "passed": true } } }, @@ -2825,7 +4036,7 @@ "stopped_by": "differential.bitexact", "verdicts": { "differential.bitexact": { - "detail": "4 subprogram(s) could not be compared: daxpy: 7 of 10 trial(s) were compared only after the free extent(s) were moved off the configured values; the 80 point(s", + "detail": "4 subprogram(s) could not be compared: daxpy: 19 draw(s) were declined (19 subscript past extent) to compare 10 trial(s); the trials compared are a minority of ", "metrics": { "bit_exact": 80, "declined": { @@ -2842,16 +4053,16 @@ "reference_isolation": "process", "subprograms": { "daxpy": { - "error": "7 of 10 trial(s) were compared only after the free extent(s) were moved off the configured values; the 80 point(s) that fit are not evidence at those extents. Pin `dims` to extents the subprogram takes" + "error": "19 draw(s) were declined (19 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" }, "dcopy": { - "error": "7 of 10 trial(s) were compared only after the free extent(s) were moved off the configured values; the 80 point(s) that fit are not evidence at those extents. Pin `dims` to extents the subprogram takes" + "error": "19 draw(s) were declined (19 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" }, "ddot": { - "error": "7 of 10 trial(s) were compared only after the free extent(s) were moved off the configured values; the 10 point(s) that fit are not evidence at those extents. Pin `dims` to extents the subprogram takes" + "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": "8 of 10 trial(s) were compared only after the free extent(s) were moved off the configured values; the 10 point(s) that fit are not evidence at those extents. Pin `dims` to extents the subprogram takes" + "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" }, "dscal": { "bit_exact": 80, @@ -2865,7 +4076,7 @@ "nan_mismatch": 0, "points": 80, "redrawn": 9, - "reshaped": 4, + "reshaped": 0, "shaped": 0 } }, @@ -2922,11 +4133,11 @@ "stopped_by": "static.rwset", "verdicts": { "static.rwset": { - "detail": "26/1576 blocks disagree: jy01a/B004, jynbh/B004, itairy/B005, cjynb/B006, cy01/B008 (+21 more)", + "detail": "9/1576 blocks disagree: jy01a/B004, jynbh/B004, itairy/B005, cjynb/B006, cy01/B008 (+4 more)", "metrics": { "blocks_checked": 1576, "blocks_deferred": 33, - "blocks_matched": 1550, + "blocks_matched": 1567, "blocks_waived": 0, "reads_excused_by_stubs": 0 }, diff --git a/corpus/toy_physics/verification.json b/corpus/toy_physics/verification.json index 4c1d1cd..00f7351 100644 --- a/corpus/toy_physics/verification.json +++ b/corpus/toy_physics/verification.json @@ -3,7 +3,7 @@ "schema": 1, "units": [ { - "candidate": "92695cfbd3eb22ad496fe5b0d7062e55f54d82b923d5871904691d6eabed7f50", + "candidate": "67ae16e8b62ac9ec14eeed62d24d2530254a24f7c158cb8d5b0a3b073244aac5", "deferred": 0, "oracle": "f2py-golden", "stopped_by": null, diff --git a/docs/cli.md b/docs/cli.md index 49a38d2..566995f 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -85,7 +85,7 @@ a path to a `.json` or `.toml` file. Same object either way, and | Recipe | Keys of its own | |---|---| -| `translate` | `target` — `numpy` (default) | +| `translate` | `target` — `numpy` (default). The transform lowers under the compiler profile of the golden oracle's `fc` (`gfortran` unless configured) so `x**2` rounds the way the reference binary does; `compiler_semantics` at the top level, or `stages.translate..profile`, overrides that | | `refactor-todo` | `reference_commit`, required. And an `executor` that is not `local`, because the gate is a batch oracle | | `audit` | none | diff --git a/docs/corpus-lapack-example.md b/docs/corpus-lapack-example.md index 21c3bf5..93bb853 100644 --- a/docs/corpus-lapack-example.md +++ b/docs/corpus-lapack-example.md @@ -144,21 +144,32 @@ had produced output. The rule is deliberately not that clever. `splines`, in the same case, shows the other side of the same rule, and it is easy to read it wrongly. Its source also says `use lapack, only: dgesv, -dgbsv`, and its emitted header has no `lapack` import at all: +dgbsv`, and with `lapack.f90` staged beside it those declarations are in +scope exactly as module procedures are, so its three LAPACK sites are +emitted as calls rather than refused: ```python -from splines_constants import * # noqa: F401,F403 -import types_numpy as _types -import utils_numpy as _utils + _lapack.dgbsv((2 * ((n - 1))), 1, 2, 1, as_, I_5, ipiv2, bmat, (2 * ((n - 1))), info) ``` -That is not because `splines` needs LAPACK any less. Its three LAPACK sites -are `call` statements, all three refused as external subroutines, so nothing -in the emitted body ever spells `_lapack.` -- and an import nothing binds to -is dropped. `splines` imports, its 51 blocks match the source's, and it -still cannot solve a spline system: the three blocks that would are the -three it deferred, standing in the output as raises. The import column -says "the file loads", and for a library over LAPACK that is all it says. +That is what `dgesv` and `dgbsv` are in `recast.references` for. Neither +build can link LAPACK, so recast defines those two itself -- Gaussian +elimination with partial pivoting, written once as the Fortran the oracle +compiles and once as the Python `lapack_numpy` carries, so the call means the +same thing and rounds the same way on both sides. `spline3` and `spline3pars` +are then compared like any other subprogram, and the verdict says what stood +in for the library: + +```text +520 points across 11 subprogram(s), all bit-exact; 2 external(s) stood in for +by recast's own reference implementation, on both sides: dgbsv, dgesv +``` + +The alternative was worse than it looks. Without a definition on either side +the oracle gives the symbol a body that error-stops, `reaching` lists every +caller as one the differential holds no reference for, and the unit passes +with its two headline subprograms never compared -- which is what it did until +`verify_recipe_candidates` learned to read the ungated list. ### The read/write check, and what it is actually reporting @@ -191,3 +202,12 @@ It is a case decision, not an engine one, and it is deliberately not made here: the corpus measures libraries as they arrive, and a shim written for one of them is the beginning of a domain package. When it is made, this page is the before. + +`recast.references` is not that shim and does not grow into one. It holds +two dense solvers, and it holds them because their contract is small enough +to state exactly and because the differential needs *the same* stand-in on +both sides -- a Python shim over `scipy.linalg.lapack` would give the +candidate the real library and leave the reference build with nothing to +link. Everything else `linalg` names -- `dgeev`, `zheevd`, `ilaenv`, thirty +more -- is still declared, still undefined, and still disclaims its callers, +and no amount of adding to that file is the right way to change it. diff --git a/src/recast/conformance/builtin.py b/src/recast/conformance/builtin.py index fd17c50..e9af272 100644 --- a/src/recast/conformance/builtin.py +++ b/src/recast/conformance/builtin.py @@ -356,18 +356,21 @@ def _python_oracle_facts() -> Facts: subroutine refused(x, y) real(r8), intent(in) :: x real(r8), intent(out) :: y - ! A formatted internal write whose format is a variable is refused on - ! purpose -- an edit descriptor is a rounding rule, and one the rules - ! cannot read at translation time cannot be rendered -- so this block + ! A SELECT TYPE over an unlimited polymorphic is refused on purpose -- + ! which branch runs is decided by the dynamic type at run time, and the + ! translation has no value whose type is decided then -- so this block ! goes to the agent queue while the one above still translates. The ! point of the case is the mixture: a Candidate that is partial rather - ! than absent. (A literal format used to stand here; the rules grew a - ! rendering for it, and a defers-case is a claim about the rules.) - character(len=32) :: buffer - character(len=8) :: fmt + ! than absent. (A literal format stood here first, then a format held in + ! a variable; the rules grew a rendering for each, and a defers-case is + ! a claim about the rules.) + class(*), allocatable :: held y = x - fmt = '(F8.2)' - write(buffer, fmt) y + allocate(held, source=y) + select type (held) + type is (real(r8)) + y = held + end select end subroutine refused end module conformance_defers diff --git a/src/recast/fortran/constants.py b/src/recast/fortran/constants.py index 926aa0a..73bbe26 100644 --- a/src/recast/fortran/constants.py +++ b/src/recast/fortran/constants.py @@ -19,7 +19,7 @@ from __future__ import annotations import re -from collections.abc import Mapping +from collections.abc import Callable, Mapping from dataclasses import dataclass, field from pathlib import Path from typing import Any @@ -144,6 +144,7 @@ def literals_with_lines( "atan2", "cos", "dble", + "digits", "epsilon", "exp", "float", @@ -156,6 +157,7 @@ def literals_with_lines( "mod", "modulo", "nint", + "radix", "real", "sign", "sin", @@ -231,22 +233,34 @@ def _argument_tokens( known_names: set[str], aliases: dict[str, str] | None = None, kinds: Kinds | None = None, + array_names: set[str] | None = None, ) -> list[dict[str, Any]] | None: """One argument as tokens of the same vocabulary; ``None`` if it names something no earlier constant defines.""" kinds = kinds or Kinds() + array_names = array_names or set() spelled: list[dict[str, Any]] = [] at = 0 while at < len(tokens): piece = tokens[at] if re.match(r"[A-Za-z_]", piece): - if piece.lower() in INTRINSICS and at + 1 < len(tokens) and tokens[at + 1] == "(": + called = at + 1 < len(tokens) and tokens[at + 1] == "(" + if piece.lower() in INTRINSICS and called: # A call inside an argument: ``max( 1.e-10, epsilon(tol) )``. - call, at = _intrinsic_call(tokens, at, known_names, aliases, kinds) + call, at = _intrinsic_call(tokens, at, known_names, aliases, kinds, array_names) if call is None: return None spelled.append(call) continue + if piece.lower() in array_names and called: + # A subscript of an earlier array parameter inside an argument: + # ``int(d1mach(5) * ...)`` keeps its machine constant an index, + # not a call this stage cannot evaluate. + index, at = _array_index(tokens, at, known_names, array_names, aliases, kinds) + if index is None: + return None + spelled.append(index) + continue if piece.lower() in known_names: spelled.append(_ref_token(piece, aliases, kinds)) elif kinds.is_kind_name(piece): @@ -378,12 +392,34 @@ def _spelled_ref(name: str, aliases: dict[str, str] | None) -> str: return (aliases or {}).get(lowered, lowered) +def _kind_selector_dtype(stripped: list[str], kinds: Kinds) -> str | None: + """The dtype a trailing kind selector written as an inquiry names. + + ``kind(1.0_wp)`` is the kind of its argument; ``selected_real_kind(p)`` + is gfortran's ``8`` for ``p >= 10`` and ``4`` otherwise. ``None`` when the + width cannot be placed, and the conversion is refused rather than guessed. + """ + head = stripped[0].lower() + inner = [token for token in stripped[2:-1] if token.strip()] + if head == "selected_real_kind" and len(inner) == 1 and re.fullmatch(r"\d+", inner[0]): + return "float64" if int(inner[0]) >= 10 else "float32" + if head == "kind" and len(inner) == 1: + token = inner[0] + if token.lower() == kinds.self_name: + return kinds.self_dtype + if re.match(r"\d", token): + return kinds.literal(token) + return kinds.dtypes.get(token.lower()) + return None + + def _intrinsic_call( tokens: list[str], at: int, known_names: set[str], aliases: dict[str, str] | None = None, kinds: Kinds | None = None, + array_names: set[str] | None = None, ) -> tuple[dict[str, Any] | None, int]: """The call starting at ``tokens[at]``, and the index just past it. @@ -414,6 +450,8 @@ def _intrinsic_call( arguments = _split_arguments(tokens[at + 2 : end]) kept = [] kind_argument: str | None = None + kind_dtype: str | None = None + saw_kind_selector = False for index, argument in enumerate(arguments): stripped = [token for token in argument if token.strip()] last = index == len(arguments) - 1 @@ -423,6 +461,20 @@ def _intrinsic_call( if last and index > 0 and len(stripped) == 3 and stripped[0].lower() == "kind": kind_argument = stripped[2] # ``kind = r8``, when the tokenizer keeps it continue + if ( + last + and index > 0 + and len(stripped) >= 4 + and stripped[1] == "(" + and stripped[0].lower() in ("kind", "selected_real_kind", "selected_int_kind") + ): + # A kind selector written as an inquiry -- ``real(radix(1.0_wp), + # kind(1.0_wp))``, the shape a module's machine-constant array + # takes -- is the result kind, not a value to pass on. Read the + # width it names so the conversion's dtype is still known. + kind_dtype = _kind_selector_dtype(stripped, kinds) + saw_kind_selector = True + continue kept.append(argument) if name in _KIND_INQUIRIES: # The argument names nothing yet defined -- the constant itself, @@ -440,12 +492,18 @@ def _intrinsic_call( if dtype not in ("float32", "float64"): return None, end + 1 return {"t": "call", "v": name, "args": [], "dtype": dtype}, end + 1 - spelled_or_none = [_argument_tokens(argument, known_names, aliases, kinds) for argument in kept] + spelled_or_none = [ + _argument_tokens(argument, known_names, aliases, kinds, array_names) for argument in kept + ] if any(text is None for text in spelled_or_none): return None, end + 1 spelled = [text for text in spelled_or_none if text is not None] if name in CONVERSIONS: - if kind_argument is not None: + if saw_kind_selector: + if kind_dtype is None: + return None, end + 1 + dtype = kind_dtype + elif kind_argument is not None: dtype = kinds.kind_argument(kind_argument) if dtype is None: return None, end + 1 @@ -670,7 +728,10 @@ def _array_index( if end >= len(tokens): return None, end + 1 subscripts = _split_arguments(tokens[at + 2 : end]) - spelled = [_argument_tokens(subscript, known_names, aliases, kinds) for subscript in subscripts] + spelled = [ + _argument_tokens(subscript, known_names, aliases, kinds, array_names) + for subscript in subscripts + ] if not spelled or any(text is None for text in spelled): return None, end + 1 return { @@ -710,7 +771,7 @@ def _classify_tokens( called = at + 1 < len(toks) and toks[at + 1] == "(" if re.match(r"[A-Za-z_]", t) and t.lower() in INTRINSICS: if called: - call, at = _intrinsic_call(toks, at, known_names, aliases, kinds) + call, at = _intrinsic_call(toks, at, known_names, aliases, kinds, array_names) if call is None: return "skip", f"unresolved argument or kind in expression: {e}" out.append(call) @@ -786,7 +847,12 @@ def classify_init( # otherwise 8; a value, because the source can compare against it. n = int(m.group(1)) return "int", 2 if n <= 4 else (4 if n <= 9 else 8) - if re.search(r"kind\s*\(", e, re.I): + if re.fullmatch(r"kind\s*\(.*\)", e, re.I | re.S): + # The whole initializer *is* a kind inquiry (``wp = kind(1.0d0)``): + # an integer selector with no runtime value to carry. A ``kind(...)`` + # buried inside a larger expression -- ``real(radix(x), kind(x))`` in + # a machine-constant array -- is a precision argument, dropped where + # it is read, not a reason to skip the whole parameter. return "skip", "kind parameter (compile-time only)" m = re.fullmatch(r"int\s*\(\s*z'([0-9a-f]+)'\s*,\s*\w+\s*\)", e, re.I) @@ -956,6 +1022,26 @@ def _holds_an_array(parameter: dict[str, Any]) -> bool: ) +def _hoist_initializer( + node: Any, subprogram: str, hoist: Callable[[str, bool, str, str], None] +) -> None: + """Give every literal in a local parameter's initializer a name. + + The initializer lives in the specification part, so the sweep over the + execution part never sees it; the constants file folds the parameter from + its own record, but the prologue re-renders the same text as a local + assignment, and one the token pass could not spell -- ``cos(94.0_wp * + deg2rad)``, ``10.0_wp**int(log(epsilon(1.0_wp)))`` -- is read again as an + expression whose literals need the names the zero-literal rule gives + everything else. Without them the parameter was refused, and with it the + whole routine that read it. + """ + for text, is_real, _line in literals_with_lines(node): + if is_whitelisted(text, is_real): + continue + hoist(text, is_real, f"{subprogram}:param", subprogram) + + def extract( path: Path, *, @@ -1184,6 +1270,7 @@ def hoist(text: str, is_real: bool, location: str, subprogram: str) -> None: if kind != "skip": local_known.add(pname) local_aliases[pname] = f"{sname}__{pname}" + _hoist_initializer(pdef.children[1], sname, hoist) for decl in walk(spec, f03.Type_Declaration_Stmt): local_type_spec, attr_list, _ = decl.children attrs = [str(a).upper() for a in (attr_list.children if attr_list else [])] @@ -1234,6 +1321,8 @@ def hoist(text: str, is_real: bool, location: str, subprogram: str) -> None: if _holds_an_array(entry): local_arrays.add(pname) literal_map[sname][f"@param:{pname}"] = const + if ent.children[3] is not None: + _hoist_initializer(ent.children[3].children[1], sname, hoist) # Declaration bounds take part in the zero-literal rule too: the # prologue that allocates capeten(pcols, 5) must name the 5. diff --git a/src/recast/fortran/frontend.py b/src/recast/fortran/frontend.py index 4935117..cfde138 100644 --- a/src/recast/fortran/frontend.py +++ b/src/recast/fortran/frontend.py @@ -483,6 +483,7 @@ def analyze(self, unit: Unit, root: Path) -> Facts: # a use-rename is looked up under the local spelling the call uses. # The operator's table wins where both name a procedure. companions, unresolved = self._companions(record, path, Path(root)) + dependencies = self._companion_dependencies(companions, own.lower(), Path(root)) # A submodule's procedures belong to its parent's namespace -- `use # parent` reaches them -- so the parent's translation re-exports them # (#29). Which submodules, and what they define, is a fact about the @@ -585,6 +586,11 @@ def analyze(self, unit: Unit, root: Path) -> Facts: # operator's config alone could only answer for a tree the # operator had already mapped by hand. "companions": companions, + # What the companions themselves ``use``. Not in scope here -- + # this unit cannot name them -- but a reference build compiles + # the companions from source, and a compiler wants every + # ``.mod`` under them. + "companion_dependencies": dependencies, # A module the tree defines and this one uses, that could not # be read. Its calls will refuse; this says why. "companions_unresolved": unresolved, @@ -864,6 +870,59 @@ def _companions( pending.extend(record_of.get("use_statements", ())) return list(found.values()), unresolved + def _companion_dependencies( + self, companions: list[dict[str, Any]], own: str, root: Path + ) -> list[dict[str, Any]]: + """Tree files the *companions* need in order to compile. + + A companion is handed to a reference build as source, and gfortran + cannot compile ``use types, only: dp`` without ``types.mod``: the + file that would produce it was never in the build, and the whole unit + stops at "cannot open module file". Visibility stops the companion + walk at an ``only`` list the used module can answer for + (``_carries_on``); a compiler stops nowhere, so the closure it needs + is wider than the one this unit can see. + + Deliberately kept apart from the companions rather than folded in. + Nothing in these modules is visible here, and putting them in scope + would let a name in this unit resolve through a module it cannot + name. They are what the build needs, and they say so. + """ + from recast.fortran import interface as interface_mod + + resolved_root = root.resolve() + index = self._module_index(resolved_root) + seen = {str(c["module"]).lower() for c in companions} | {own} + found: dict[str, dict[str, Any]] = {} + pending: list[str] = [] + for companion in companions: + pending.extend(companion.get("record", {}).get("use_statements", ())) + while pending: + statement = pending.pop(0) + match = USE_STATEMENT.match(statement.strip()) + if not match: + continue + module = match.group("module").lower() + if module in seen or module in INTRINSIC_MODULES or module in self.stub_modules: + continue + seen.add(module) + source = index.get(module) + if source is None: + continue + # A sibling that does not parse costs the build the file it would + # have named; the unit still stops at the compiler, and says so + # there rather than here. + record_of = self._readable(source, interface_mod.extract, module) + if record_of is None: + continue + found[module] = { + "module": module, + "source": str(source.relative_to(resolved_root)), + "record": record_of, + } + pending.extend(record_of.get("use_statements", ())) + return [found[name] for name in sorted(found)] + @staticmethod def _only_names(match: re.Match[str]) -> set[str]: """The local names a ``use, only:`` list lets in (``a => b`` lets ``a``).""" diff --git a/src/recast/fortran/interface.py b/src/recast/fortran/interface.py index f4d3b2e..835fa1e 100644 --- a/src/recast/fortran/interface.py +++ b/src/recast/fortran/interface.py @@ -29,6 +29,7 @@ from __future__ import annotations import re +from collections.abc import Callable from pathlib import Path from typing import Any @@ -317,6 +318,8 @@ def dtype_of(base_type: str | None, kind: str | None, kind_map: dict[str, str]) return "complex64" return f"UNKNOWN_COMPLEX_KIND({k})" if bt.startswith("DOUBLE"): + # DOUBLE PRECISION is float64; DOUBLE COMPLEX was handled above and + # reads as complex128, so what reaches here is a real double. return "float64" return f"UNKNOWN({bt})" @@ -503,6 +506,203 @@ def apply_dimension_stmts( entity["array_spec"] = entity.get("array_spec") or shape["array_spec"] +def opened_files(execution: Any, arg_names: list[str]) -> dict[str, str]: + """Dummy arguments an OPEN in this body names as its ``FILE=``, and what + the connection asks of the file: ``"existing"`` where any OPEN of it says + ``STATUS='OLD'``, ``"created"`` otherwise. + + Which dummy is a path is not something a type can say -- ``character(len=*) + :: filename`` and ``character(len=*) :: msg`` are declared identically, and + only the body tells them apart. A consumer that has to *supply* one needs + the distinction: a path the subprogram creates is a scratch name any caller + may choose, and the file left at it is the subprogram's whole output where + it has no output argument at all (``saveppm``); a path it opens + ``STATUS='OLD'`` is a file that has to already hold something the caller + did not write, which is not a value anything can draw. + """ + if execution is None: + return {} + dummies = {name.lower() for name in arg_names} + found: dict[str, str] = {} + for stmt in walk(execution, f03.Open_Stmt): + specifiers = stmt.children[1] + named = None + status = "unknown" + for spec in specifiers.children if hasattr(specifiers, "children") else [specifiers]: + keyword, value = spec.children + key = str(keyword).upper() if keyword is not None else None + if key == "FILE": + text = str(value).strip().lower() + named = text if text in dummies else None + elif key == "STATUS": + status = str(value).strip().strip("'\"").lower() + if named is None: + continue + if status == "old" or found.get(named) == "existing": + found[named] = "existing" + else: + found.setdefault(named, "created") + return found + + +def file_units(execution: Any) -> list[str]: + """Local names an OPEN in this body connects to a named file. + + A WRITE to one of these puts records in a file the translation itself + created, and for a subprogram whose only product is that file -- + ``saveppm`` takes an image and returns nothing -- the records *are* the + translation. A WRITE to any other unit goes to a stream someone else + connected: standard output, or a unit number the caller opened. Those are + logs, and dropping them is the reading the emitter has always taken. + """ + if execution is None: + return [] + found: set[str] = set() + for stmt in walk(execution, f03.Open_Stmt): + specifiers = stmt.children[1] + named = None + has_file = False + for at, spec in enumerate( + specifiers.children if hasattr(specifiers, "children") else [specifiers] + ): + keyword, value = spec.children + key = str(keyword).upper() if keyword is not None else None + if key == "FILE": + has_file = True + elif key in ("UNIT", "NEWUNIT") or (key is None and at == 0): + text = str(value).strip().lower() + named = text if re.fullmatch(r"[a-z_]\w*", text) else None + if has_file and named is not None: + found.add(named) + return sorted(found) + + +SHAPE_GUARD = re.compile( + r"\Asize\(\s*(?P\w+)\s*(?:,\s*(?P\d+)\s*)?\)\s*/=\s*(?P.+)\Z", + re.I | re.S, +) +"""``if (size(c,1) /= 5) call stop_error(...)``: the subject and the extent.""" + +SIZE_TERM = re.compile(r"size\(\s*(\w+)\s*(?:,\s*(\d+)\s*)?\)", re.I) + +ARITHMETIC_ONLY = re.compile(r"\A[\d\s+\-*/()]*\Z") + + +def shape_guards(execution: Any, arg_names: list[str]) -> list[dict[str, Any]]: + """What a body's own entry checks say the shapes of its dummies must be. + + ``if (size(c,1) /= 5) call stop_error("size(c,1) /= 5")`` is not a + diagnostic aside: it is the declaration the language had no way to make. + ``c(0:,:)`` is assumed-shape, so the interface says nothing about either + extent, and the two lines under it say the first is five and the second + is one less than the length of ``xi``. A consumer that has to *supply* a + shape -- a differential harness drawing arguments -- has no other source + for that, and every shape it invents is one this subprogram stops on + before computing anything. + + Only the ``/=`` form, and only over dummy arguments: a guard that stops + unless two things are equal is a statement that they are equal, where + ``<``/``>`` bounds a range with no single answer in it, and an extent + written in terms of a local says nothing a caller can act on. The extent + is returned as text over ``size(name, axis)`` terms and integer + arithmetic, which is as far as this can go without evaluating anything. + """ + if execution is None: + return [] + dummies = {name.lower() for name in arg_names} + found: list[dict[str, Any]] = [] + seen: set[tuple[str, int]] = set() + for statement in (*walk(execution, f03.If_Stmt), *walk(execution, f03.If_Then_Stmt)): + condition = statement.children[0] + match = SHAPE_GUARD.fullmatch(" ".join(str(condition).split())) + if match is None: + continue + subject = match.group("arg").lower() + axis = int(match.group("axis") or 1) - 1 + if subject not in dummies or axis < 0 or (subject, axis) in seen: + continue + extent = match.group("extent").strip() + terms = SIZE_TERM.findall(extent) + if any(term[0].lower() not in dummies for term in terms): + continue + spelled = SIZE_TERM.sub( + lambda m: f"size({m.group(1).lower()},{int(m.group(2) or 1) - 1})", extent + ) + if not ARITHMETIC_ONLY.fullmatch(SIZE_TERM.sub("0", spelled)): + continue + seen.add((subject, axis)) + found.append({"arg": subject, "axis": axis, "extent": spelled}) + return found + + +VALUE_GUARD_TERM = re.compile( + r"\A(?P\w+)\s*(?:\(\s*\d+\s*\))?\s*(?P<=?|>=?)\s*(?P[+-]?\d+)\Z" +) +"""One side of ``if (bctype(1) < 1 .or. bctype(1) > 2) call stop_error(...)``.""" + + +def value_guards(execution: Any, args: list[dict[str, Any]]) -> list[dict[str, Any]]: + """What a body's own entry checks say the *values* of its dummies must be. + + ``if (bctype(1) < 1 .or. bctype(1) > 2) call stop_error("bctype /= 1 or 2")`` + is the counterpart of ``shape_guards``: not a diagnostic aside but the + declaration the language had no way to make. ``bctype`` is a plain + ``integer`` dummy, so nothing in the interface says it is a mode selector + with two modes, and a harness drawing an integer from its own default + range hits one of them once in sixteen tries -- which is how ``spline3pars`` + came out with no draw either side would take. + + Only integer dummies, and only a guard that bounds one from both sides: + the pair reads as "this argument lies in [low, high]", which is a range a + caller can act on, where a one-sided bound leaves the other end wherever + the default had it and says nothing about the mode. The bounds are integer + literals, so this evaluates nothing. + + And only the one-line ``if (...) call ...`` / ``stop`` form, where the + body *refuses* the value. ``if (k < 1 .or. k > 4) k = 1`` is the opposite + statement -- the subprogram takes any ``k`` and clamps it -- and reading + it as a range would quietly narrow every draw to the branch that does + nothing, which is a coverage loss no verdict would mention. + """ + if execution is None: + return [] + integers = { + str(arg["name"]).lower() for arg in args if str(arg.get("dtype")) in ("int32", "int64") + } + refusals = (f03.Call_Stmt, f03.Stop_Stmt, f08.Error_Stop_Stmt, f03.Return_Stmt) + found: list[dict[str, Any]] = [] + seen: set[str] = set() + for statement in walk(execution, f03.If_Stmt): + if not isinstance(statement.children[1], refusals): + continue + text = " ".join(str(statement.children[0]).split()) + parts = re.split(r"\.or\.", text, flags=re.I) + if len(parts) != 2: + continue + bounds: dict[str, int] = {} + subject = "" + for part in parts: + match = VALUE_GUARD_TERM.fullmatch(part.strip()) + if match is None: + break + name = match.group("arg").lower() + if subject and name != subject: + break + subject = name + operator, bound = match.group("op"), int(match.group("bound")) + if operator.startswith("<"): + bounds["low"] = bound + (1 if operator == "<=" else 0) + else: + bounds["high"] = bound - (1 if operator == ">=" else 0) + if subject not in integers or subject in seen or len(bounds) != 2: + continue + if bounds["low"] > bounds["high"]: + continue + seen.add(subject) + found.append({"arg": subject, "low": bounds["low"], "high": bounds["high"]}) + return found + + def sub_name_of(sub: Any) -> str: """Lowercased name of a subprogram node.""" stmt = walk(sub, (f03.Subroutine_Stmt, f03.Function_Stmt))[0] @@ -731,6 +931,7 @@ def _record_of( "intent": d["intent"], "optional": "OPTIONAL" in d["attrs"], "parameter": "PARAMETER" in d["attrs"], + "allocatable": "ALLOCATABLE" in d["attrs"], "array_spec": e["array_spec"] or next( (a.split(":", 1)[1] for a in d["attrs"] if a.startswith("DIMENSION:")), None @@ -830,6 +1031,7 @@ def _record_of( info["dtype"] = "PROCEDURE" dummy_procedures = procedure_declarations(spec) + paths = opened_files(execution, arg_names) args: list[dict[str, Any]] = [] for pos, an in enumerate(arg_names): info = ent_info.get(an, {}) @@ -845,7 +1047,16 @@ def _record_of( "dims": info.get("dims"), "char_len": info.get("char_len"), "line": info.get("line"), + # An ALLOCATABLE dummy is not the caller's storage: intent(out) + # deallocates it on entry and the callee decides its extent, so + # every consumer that reasons about who owns an out array has to + # be able to tell it from an assumed-shape one, which is spelled + # the same way here (deferred upper bounds). + **({"allocatable": True} if info.get("allocatable") else {}), **({"procedure": True} if info.get("procedure") else {}), + # A character dummy an OPEN in this body names as its FILE=, and + # what that OPEN asks of the file (see ``opened_files``). + **({"path": paths[an]} if an in paths else {}), } # A dummy procedure is not data: it carries the interface it was # declared with instead of a dtype, and every consumer that asks @@ -887,6 +1098,9 @@ def _record_of( "dtype": i["dtype"], "array_spec": i["array_spec"], "dims": i.get("dims"), + # ``character(len=80) :: line``: the length an A edit descriptor + # reads, which is a fact about the local and not only about a dummy. + "char_len": i.get("char_len"), # ``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"), @@ -900,6 +1114,7 @@ def _record_of( present_args: list[str] = [] calls: list[str] = [] + outside: set[str] = set() state_read: set[str] = set() state_written: set[str] = set() if exec_part is not None: @@ -914,6 +1129,8 @@ def _record_of( cn = str(call.children[0]).lower() if cn in module_sub_names: calls.append(cn) + elif "%" not in cn: + outside.add(cn) # An arg-less function reference is a bare Name in the expression, not a # Part_Ref, so the two forms have to be caught separately. used = set(names_in(exec_part)) @@ -946,6 +1163,26 @@ def _record_of( folded = _fold_local_parameter_bounds(args, result_dims, local_parameters) + # Names this body reaches that this module does not define: a companion's + # procedure, an intrinsic, a use-imported array read like a call. Written + # generously on purpose -- every consumer intersects it with a set of its + # own (which procedures a build leaves undefined, say), and a name missing + # here is a reach nobody can see, while a name too many costs an + # intersection that comes back empty. + reached = { + str(ref.children[0]).lower() + for ref in walk(exec_part, f03.Part_Ref) + if exec_part is not None + } + outside |= { + candidate + for candidate in reached + if candidate not in module_sub_names + and candidate not in ent_info + and candidate not in arg_names + and candidate != name + } + return { "name": name, "kind": kind, @@ -960,8 +1197,17 @@ def _record_of( "locals": locals_, "present_calls": sorted(set(present_args)), "calls": sorted({c for c in calls if c != name}), + # What the body reaches outside this module (see above). + "external_calls": sorted(outside - {name}), "module_state_read": sorted(state_read), "module_state_written": sorted(state_written), + # Unit variables an OPEN here connects to a file (see ``file_units``). + "file_units": file_units(exec_part), + # What this body's own checks say its dummies' shapes must be + # (see ``shape_guards``). + "shape_guards": shape_guards(exec_part, arg_names), + # ... and their values (see ``value_guards``). + "value_guards": value_guards(exec_part, args), } @@ -1129,11 +1375,38 @@ def _derived_types( return types +def _public_types(mod_spec: Any, is_public: Callable[[str], bool]) -> list[str]: + """The derived types the module exports, by name. + + ``type, public :: t`` and ``type, private :: t`` decide on the type + statement itself; a type declaring neither follows the module's default + and its ``public``/``private`` lists, the same way a subprogram does. + """ + if mod_spec is None: + return [] + exported: list[str] = [] + for statement in walk(mod_spec, f03.Derived_Type_Stmt): + name = str(statement.children[1]).lower() + attributes = [str(a).upper() for a in walk(statement.children[0], f03.Access_Spec)] + if "PUBLIC" in attributes: + exported.append(name) + elif "PRIVATE" not in attributes and is_public(name): + exported.append(name) + return sorted(set(exported)) + + def _interfaces( - mod_spec: Any, kind_map: dict[str, str], state_names: set[str], sub_names: set[str] + mod_spec: Any, + kind_map: dict[str, str], + state_names: set[str], + sub_names: set[str], + subs: list[Any] | None = None, ) -> dict[str, Any]: """``{name: subprogram record}`` for the bodies of interface blocks. + ``mod_spec`` is the module's specification part; ``subs`` are the + subprogram nodes whose own specification parts are searched as well. + An explicit interface for a procedure the module does not define -- the shape a procedure dummy has to have (``external :: func`` in a solver, ``interface / subroutine func(...)`` above it) -- and an ``abstract @@ -1145,9 +1418,19 @@ def _interfaces( as a subprogram's so the call renderer needs no second path. """ interfaces: dict[str, Any] = {} - if mod_spec is None: - return interfaces - for ib in walk(mod_spec, f03.Interface_Block): + blocks = list(walk(mod_spec, f03.Interface_Block)) if mod_spec is not None else [] + # A subprogram may declare an interface in its own specification part -- + # ``polyroots`` declares LAPACK's ``dgeev`` right where it calls it. The + # declaration is as real as one at module level: it is what the compiler + # checked the call against, what the reference build has to stub because + # nothing defines it, and what the translation binds the call to. Left + # uncollected, the call was refused as an unknown external and the build + # linked against nothing of the name. + for sub in subs or (): + spec = next((c for c in sub.children if isinstance(c, f03.Specification_Part)), None) + if spec is not None: + blocks.extend(walk(spec, f03.Interface_Block)) + for ib in blocks: # ``interface gen`` names a generic: its specifics are procedures, see # _generics. ``abstract interface`` is spelled with the same slot. if any( @@ -1157,7 +1440,9 @@ def _interfaces( continue for body in walk(ib, (f03.Subroutine_Body, f03.Function_Body)): record = extract_subprogram(body, kind_map, state_names, sub_names) - interfaces[record["name"]] = record + # The module's own declaration first; a subprogram's is not + # allowed to redefine what the module already said. + interfaces.setdefault(record["name"], record) return interfaces @@ -1386,8 +1671,13 @@ def is_public(name: str) -> bool: "module_allocate_bounds": allocated_bounds, "public": sorted(set(public_names)), "types": _derived_types(mod_spec, kind_map, scope=sub_scope, visible=visible | imported), + # Which of those types the module exports. A reference wrapper that + # spells a derived-type dummy component by component has to ``use`` + # the type, and ``type, public :: t`` is an attribute of the type + # statement, not an ``Access_Stmt`` the public list above sees. + "public_types": _public_types(mod_spec, is_public), "generics": generics, - "interfaces": _interfaces(mod_spec, kind_map, state_names, sub_names), + "interfaces": _interfaces(mod_spec, kind_map, state_names, sub_names, subs=subs), "buffer_convention": buffer_convention, "subprograms": subprograms, } @@ -1404,7 +1694,21 @@ def companion_externals(record: dict[str, Any]) -> dict[str, dict[str, Any]]: transcribed by hand, which is how they would drift. """ table: dict[str, dict[str, Any]] = {} - for sub in record["subprograms"]: + for sub in ( + *record["subprograms"], + # A procedure the sibling declares through an INTERFACE block and does + # not define -- an interface module over a compiled library. It is a + # procedure by declaration, and a reader that does not know that takes + # ``nb = ilaenv(1, ...)`` for an array being subscripted. Its dummies + # carry no INTENT, so no position is known to be written, which is the + # same thing the translation can say about the call. + *( + declared + for declared in (record.get("interfaces") or {}).values() + if declared.get("kind") in ("subroutine", "function") + and declared["name"] not in {s["name"] for s in record["subprograms"]} + ), + ): table[sub["name"]] = { "kind": sub["kind"], "out_positions": [ @@ -1420,7 +1724,10 @@ def companion_externals(record: dict[str, Any]) -> dict[str, dict[str, Any]]: ], # What the caller reads: IN and INOUT actuals, and a buffer OUT. # An INOUT actual is written *and* read; out_positions alone said - # only the first. + # only the first. This is the superset the lab line spelled as a + # separate ``inout_positions`` (INOUT, or a buffer OUT the + # translation passes in and unpacks -- ``dscal(n, alpha, s, 1)`` + # into a sibling BLAS): read_positions already carries both. "read_positions": [ at for at, argument in enumerate(sub["args"]) @@ -1451,7 +1758,16 @@ def companion_externals(record: dict[str, Any]) -> dict[str, dict[str, Any]]: # argument count, and the scope picks by the actuals it sees. A # union over specifics of different arity marked the wrong # positions -- CLUBB's tridiag_solve, zm2zt_api. - signatures = {s: next(x for x in record["subprograms"] if x["name"] == s) for s, _ in known} + # A specific the module only declares (fftpack's ``dct_t1i`` over the + # bare ``dcosti``) has its signature in the interface block, not + # among the definitions. + by_name = { + d["name"]: d + for d in (record.get("interfaces") or {}).values() + if d.get("kind") in ("subroutine", "function") + } + by_name.update({x["name"]: x for x in record["subprograms"]}) + signatures = {s: by_name[s] for s, _ in known} arity = {s: len(sig["args"]) for s, sig in signatures.items()} required = { s: sum(1 for a in sig["args"] if not a.get("optional")) for s, sig in signatures.items() @@ -1782,9 +2098,18 @@ def _mark_buffer_out_arrays(records: list[dict[str, Any]], every: bool = False) Fortran passes array storage, so an ``intent(out)`` array whose extent this subprogram cannot derive was allocated by the caller and has to stay a parameter -- returned like an INOUT rather than created here. It cannot - derive one when the dummy is assumed-size (``a(*)``), or assumed-shape - with neither a same-rank assumed-shape IN/INOUT donor to take the shape - from nor explicit-bound IN arguments covering every dimension. + derive one when the dummy is assumed-size (``a(*)``) or assumed-shape + (``x(:)``): either way the extent is the actual's, and only the caller + has it. + + An assumed-shape OUT used to borrow the shape of a same-rank + assumed-shape IN/INOUT sibling instead. That donor is a guess about the + caller, and BVLS is where it is wrong: ``x(:)`` and ``w(:)`` are + ``n``-vectors (the columns of ``a``) while the only rank-1 donor, ``b(:)``, + is an ``m``-vector -- so the translation sized its solution off the + wrong axis, and the f2py reference, which cannot allocate an + ``intent(out)`` dummy of extent ``:`` at all, died on every call. The + caller's storage is the one answer that is right on both sides. Without the mark the return convention drops the argument from the signature and allocates a fresh array, so the caller's buffer is never @@ -1801,34 +2126,47 @@ def _mark_buffer_out_arrays(records: list[dict[str, Any]], every: bool = False) ): continue dims = argument["dims"] - if every or any(d.get("assumed_size") for d in dims): - argument["buffer"] = True - continue - if not all(d.get("ub") is None for d in dims): - continue - rank = len(dims) - donor = any( - other.get("intent") in ("IN", "INOUT") - and len(other.get("dims") or []) == rank - and any(d.get("ub") is None for d in other["dims"]) - for other in args - ) - if donor: - continue - explicit = [ - other - for other in args - if other.get("intent") in ("IN", "INOUT") - and other.get("dims") - and all(d.get("ub") is not None for d in other["dims"]) - ] - covered = all( - any(axis < len(other["dims"]) for other in explicit) for axis in range(rank) - ) - if not covered: + if every or any(d.get("ub") is None for d in dims): argument["buffer"] = True +def _callee_intents(records: list[dict[str, Any]]) -> dict[str, list[tuple[str, str]]]: + """Callee name -> its dummies as ``(name, intent)`` pairs, in position + order, with the intent each has so far, declared or inferred, for the + escape analysis of its callers. + + Two same-named subprograms (internals of different hosts) are merged + conservatively: a position keeps its intent only where both agree, and is + ``UNKNOWN`` otherwise, which the caller of this table reads as "might be + written". Under-reporting a write is the one direction this table is not + allowed to err in. + """ + table: dict[str, list[tuple[str, str]]] = {} + for record in records: + name = str(record["name"]).lower() + pairs = [ + (str(argument["name"]).lower(), str(argument["intent"])) + for argument in record.get("args") or [] + ] + if name not in table: + table[name] = pairs + continue + previous = table[name] + width = max(len(previous), len(pairs)) + merged: list[tuple[str, str]] = [] + for position in range(width): + here = pairs[position] if position < len(pairs) else None + there = previous[position] if position < len(previous) else None + if here is not None and there is not None and here[1] == there[1]: + merged.append(here) + else: + known = here if here is not None else there + assert known is not None # width is the longer of the two + merged.append((known[0], "UNKNOWN")) + table[name] = merged + return table + + def _written_or_escaping( exec_part: Any, sub_names: set[str], @@ -1839,7 +2177,10 @@ def _written_or_escaping( A name is here if it is assigned to, if it controls a DO, if a READ fills it or a WRITE takes it as the internal unit, if an ALLOCATE, DEALLOCATE, - NULLIFY or INQUIRE names it, if an ASSOCIATE takes it as a selector -- or + NULLIFY or INQUIRE names it, if an ASSOCIATE takes it as a selector and + the associate-name is itself here (the alias is the selector: a body that + only reads ``a`` in ``associate (a => x%c)`` reads ``x``; one that assigns + ``a`` or hands it to a writer changes ``x``, #49) -- or if it is handed to something that might write it: a CALL, a function reference, or a parenthesised reference whose base is not a variable this scope declares. An intrinsic never writes its argument and a subscript of @@ -1850,7 +2191,8 @@ def _written_or_escaping( own subprograms are calls, as before. ``callee_intents`` maps this file's subprograms to their dummies ``(name, intent)`` in order: an actual handed to a dummy declared or inferred ``intent(in)`` is read, - not handed over, and does not escape. + not handed over, and does not escape (``call dcbcrt(a(2), zr(2), zi(2))`` + reads ``a`` and writes the other two). """ escaping: set[str] = set() intents_of = callee_intents or {} @@ -1905,6 +2247,15 @@ def variable_actual(item: Any) -> str | None: return leftmost(item) return None + # (associate-name, the variable its selector is rooted in): the alias is + # a variable of the construct, and the selector escapes with it. + aliases = [ + (str(association.children[0]).lower(), leftmost(association.children[2])) + for association in walk(exec_part, f03.Association) + ] + if variables is not None and aliases: + variables = variables | {alias for alias, _ in aliases} + for assignment in walk(exec_part, (f03.Assignment_Stmt, f03.Pointer_Assignment_Stmt)): name = leftmost(assignment.children[0]) if name: @@ -1932,10 +2283,6 @@ def variable_actual(item: Any) -> str | None: key, value = spec.children if key in (None, "UNIT") and isinstance(value, f03.Name): escaping.add(str(value).lower()) - for association in walk(exec_part, f03.Association): - name = leftmost(association.children[2]) - if name: - escaping.add(name) for call in walk(exec_part, f03.Call_Stmt): callee = leftmost(call.children[0]) or "" arguments = call.children[1] @@ -1969,15 +2316,52 @@ def variable_actual(item: Any) -> str | None: continue for name in walk(reference.children[1], f03.Name): escaping.add(str(name).lower()) + # A selector escapes exactly when its associate-name does; a selector + # that is itself an alias (``associate (b => a(1:2))`` under + # ``associate (a => x)``) carries it one association further. + changed = True + while changed: + changed = False + for alias, root in aliases: + if root is not None and alias in escaping and root not in escaping: + escaping.add(root) + changed = True return escaping -def _callee_intents(records: list[dict[str, Any]]) -> dict[str, list[tuple[str, str]]]: - """Each subprogram's dummies with the intent they have so far, declared - or inferred, for the escape analysis of its callers.""" - return { - record["name"]: [(a["name"], a["intent"]) for a in record["args"]] for record in records - } +def _written_through_calls( + exec_part: Any, callee_intents: dict[str, list[tuple[str, str]]] +) -> set[str]: + """Names a CALL hands, whole and positionally, to a dummy the callee + declares or has been inferred to write (``OUT``/``INOUT``). + + The decided half of ``_written_or_escaping``'s call rule: that one says + what *might* be written, this one what the callee's own interface says + *is*. A bare name only -- ``call vrshft(l3, zr, zi, conv)`` -- because a + subscripted actual writes one element of an array, which is the array + rule's business, not a scalar's. ``callee_intents`` maps each callee to + its dummies as ``(name, intent)`` pairs, so the intent is the second of + each. + """ + settled: set[str] = set() + for call in walk(exec_part, f03.Call_Stmt): + arguments = call.children[1] + formals = callee_intents.get(str(call.children[0]).lower()) + if arguments is None or formals is None: + continue + actuals = ( + list(arguments.children) + if isinstance(arguments, f03.Actual_Arg_Spec_List) + else [arguments] + ) + for position, actual in enumerate(actuals): + if ( + isinstance(actual, f03.Name) + and position < len(formals) + and formals[position][1] in ("OUT", "INOUT") + ): + settled.add(str(actual).lower()) + return settled def _infer_read_only_intents( @@ -1999,33 +2383,77 @@ def _infer_read_only_intents( a procedure this file does not define, to an internal WRITE or an ASSOCIATE stays UNKNOWN, and the gate keeps refusing the routine by name rather than comparing it with an output missing on both sides. + + An *array* dummy the body does change -- assigned into, or handed to + something that might write it -- is ``intent(inout)``. Not ``out``: the + caller's storage is what Fortran passes, a body that writes ``zr(1:3)`` + of its ``zr(4)`` leaves the rest as the caller had it, and INOUT is the + convention under which both sides hand that buffer back. A *scalar* the + body only hands on is settled the same way when the callee has settled + it: passed to a dummy declared or inferred ``intent(out)``/``inout`` it + is written there, and INOUT here carries the value back -- ``cpoly``'s + ``fxshft(l2, zr, zi, conv)`` passes ``conv`` straight to ``vrshft``, + which sets it, and left UNKNOWN the translation never handed the flag + back and reported every polynomial as a failure. Passed to a procedure + with no body here it stays UNKNOWN. The scalar assignment rules stay + ``_infer_write_only_intents``'s. The bodies read are every execution + part under the subprogram, its internal procedures' included, because a + host dummy an internal procedure writes is written. Repeated to a + fixpoint, because a callee's inferred intent is what settles its + caller's. """ by_name = {sub_name_of(s): s for s in subs} - for record in records: - candidates = [ - argument - for argument in record["args"] - if argument["intent"] == "UNKNOWN" - and not argument.get("optional") - and not argument.get("procedure") - ] - node = by_name.get(record["name"]) - if not candidates or node is None: - continue - exec_part = next((c for c in node.children if isinstance(c, f03.Execution_Part)), None) - if exec_part is None: - continue - variables = ( - {a["name"] for a in record["args"]} - | {local["name"] for local in record.get("locals") or []} - | {p["name"] for p in record.get("local_parameters") or []} - | set(state_names or ()) - ) - escaping = _written_or_escaping(exec_part, sub_names, variables, _callee_intents(records)) - for argument in candidates: - if argument["name"] not in escaping: - argument["intent"] = "IN" - argument["intent_inferred"] = "read-only" + changed = True + while changed: + changed = False + callee_intents = _callee_intents(records) + for record in records: + candidates = [ + argument + for argument in record["args"] + if argument["intent"] == "UNKNOWN" + and not argument.get("optional") + and not argument.get("procedure") + ] + node = by_name.get(record["name"]) + if not candidates or node is None: + continue + exec_parts = walk(node, f03.Execution_Part) + if not exec_parts: + continue + # The names this scope declares, so that a reference whose base is + # none of them and not a subprogram of this file is taken for a + # use-associated or external call whose variable actuals escape + # (#33) -- the escaping-dummy rule the read-only proof rests on. + variables = ( + {a["name"] for a in record["args"]} + | {local["name"] for local in record.get("locals") or []} + | {p["name"] for p in record.get("local_parameters") or []} + | set(state_names or ()) + ) + escaping: set[str] = set() + settled: set[str] = set() + for exec_part in exec_parts: + escaping |= _written_or_escaping(exec_part, sub_names, variables, callee_intents) + settled |= _written_through_calls(exec_part, callee_intents) + for argument in candidates: + if argument["name"] not in escaping: + argument["intent"] = "IN" + argument["intent_inferred"] = "read-only" + changed = True + elif _intent_inferable(argument.get("dtype")) and argument["name"] in settled: + # Written through a call this file can see -- ``cpoly``'s + # ``conv`` handed to ``vrshft``, which sets it. INOUT + # carries the value back. An array the body writes in + # place, whole or in part, is already settled by + # ``_infer_write_only_intents`` (which handles arrays now, + # not scalars only); what is left UNKNOWN here is an array + # only *passed on* to a procedure this file does not + # describe, whose fate is the callee's -- so a bare + # ``dims`` must not force it to INOUT (#33). + argument["intent"] = "INOUT" + argument["intent_inferred"] = "written" + changed = True def _host_associate( @@ -2042,12 +2470,28 @@ def _host_associate( so the record has to say which they are: names used in the internal procedure's execution part that are not its own, but are declared by the host. As the pipeline's ``extract_interface`` does it. + + Two more things the record has to say, because a Python scalar goes in + by value and comes back only if it is returned: + + * ``host_vars`` is transitive over the host's other internal procedures. + ``fxshfr`` never names ``a1`` but calls ``calcsc``, which does; the + translation's call passes the callee's host variables as trailing + actuals, so the caller has to have received them -- without this it + raised ``NameError`` on the first name it was never handed; + * ``host_writes`` names the host variables the body may change (assigned, + or handed to something that might write them), its callees' included. + The translation returns those beside the procedure's own outputs and + the call site takes them back, the way an INOUT dummy travels. Without + it ``calcsc``'s ``a1 = b*f - a`` was computed into a local and dropped, + and every routine of ``rpoly`` after it ran on the host's stale value. """ parents: dict[int, Any] = {} for s in subs: for inner in walk(s, (f03.Subroutine_Subprogram, f03.Function_Subprogram)): if inner is not s: parents[id(inner)] = s + internals: list[tuple[Any, dict[str, Any], set[str], set[str]]] = [] for s, rec in zip(subs, records, strict=True): parent = parents.get(id(s)) if parent is None: @@ -2079,6 +2523,8 @@ def _host_associate( host_vars = sorted((used & host_names) - own - sub_names - state_names) if host_vars: rec["host_vars"] = host_vars + internals.append((s, rec, own, host_names - sub_names - state_names)) + _host_closure(internals, sub_names, records) # Two internal procedures of different hosts with one name cannot both # be `def func` in one file. seen: dict[str, int] = {} @@ -2089,6 +2535,75 @@ def _host_associate( rec["emit_name"] = f"{rec['host']}__{rec['name']}" +def _host_closure( + internals: list[tuple[Any, dict[str, Any], set[str], set[str]]], + sub_names: set[str], + records: list[dict[str, Any]], +) -> None: + """Close ``host_vars`` over sibling calls and record ``host_writes``. + + ``internals`` carries each internal procedure's node, record, own names + and the host names visible to it. A sibling is another internal + procedure of the same host that this one calls or references; what the + sibling needs from the host, this one has to be handed too, and what the + sibling changes, this one changes. Both are fixpoints: siblings call + each other in chains and cycles (``fxshfr`` -> ``quadit`` -> ``calcsc`` + -> ``nextk`` -> ...). + """ + by_host: dict[str, dict[str, dict[str, Any]]] = {} + for _, rec, _, _ in internals: + by_host.setdefault(rec["host"], {})[rec["name"]] = rec + siblings: dict[int, list[dict[str, Any]]] = {} + written: dict[int, set[str]] = {} + callee_intents = _callee_intents(records) + for node, rec, _, _ in internals: + parts = [c for c in node.children if isinstance(c, f03.Execution_Part)] + referenced: set[str] = set() + changes: set[str] = set() + for part in parts: + referenced |= {str(call.children[0]).lower() for call in walk(part, f03.Call_Stmt)} + referenced |= { + str(ref.children[0]).lower() + for ref in walk(part, (f03.Part_Ref, f03.Function_Reference)) + if isinstance(ref.children[0], f03.Name) + } + changes |= _written_or_escaping(part, sub_names, callee_intents=callee_intents) + same_host = by_host[rec["host"]] + siblings[id(rec)] = [ + same_host[name] + for name in sorted(referenced) + if name in same_host and name != rec["name"] + ] + written[id(rec)] = changes + changed = True + while changed: + changed = False + for _, rec, own, host_names in internals: + have = set(rec.get("host_vars") or ()) + for sibling in siblings[id(rec)]: + extra = (set(sibling.get("host_vars") or ()) & host_names) - own - have + if extra: + have |= extra + changed = True + if have and have != set(rec.get("host_vars") or ()): + rec["host_vars"] = sorted(have) + for _, rec, _, _ in internals: + written[id(rec)] &= set(rec.get("host_vars") or ()) + changed = True + while changed: + changed = False + for _, rec, _, _ in internals: + have = written[id(rec)] + for sibling in siblings[id(rec)]: + extra = (written[id(sibling)] & set(rec.get("host_vars") or ())) - have + if extra: + have |= extra + changed = True + for _, rec, _, _ in internals: + if written[id(rec)]: + rec["host_writes"] = sorted(written[id(rec)]) + + CONFLICTING_BOUNDS = "conflicting" """A module allocatable allocated with lower bounds that do not agree, or with one no other subprogram can evaluate. A reference to it cannot be diff --git a/src/recast/fortran/intrinsics.py b/src/recast/fortran/intrinsics.py index 55a51b8..ae06c85 100644 --- a/src/recast/fortran/intrinsics.py +++ b/src/recast/fortran/intrinsics.py @@ -22,6 +22,7 @@ { "abs", "acos", + "achar", "adjustl", "aimag", "aint", @@ -88,6 +89,7 @@ "mvbits", "nint", "precision", + "radix", "real", "scan", "shape", @@ -143,6 +145,37 @@ rather than at one index. """ +LOCATION = frozenset({"maxloc", "minloc"}) +"""Report *where* an array's extreme value is, not what it is. + +Kept apart from ``TRANSFORMATIONAL`` because their result is a rank-1 position +vector unless DIM is given, so this is not a set a rank query may answer 0 +for; the read/write analysis needs only the membership. Named here because +``minloc(a2(i:), 1)`` is a call: counting the name as a variable read makes +every block holding one disagree with a translation that spells it +``np.argmin``, which is what failed ``iargsort`` and ``rargsort`` of the +corpus's sorting module. +""" + +RESHAPING = frozenset({"spread"}) +"""Rearrange an array into another array rather than collapsing it. + +Kept apart from ``TRANSFORMATIONAL`` for the reason ``LOCATION`` is: the +result is an array, so this is not a set a rank query may answer 0 for, and +the read/write analysis needs only the membership. Named here because +``spread(x, 1, size(y))`` is a call: counting the name as a variable read +makes every block holding one disagree with a translation that spells it +``np.repeat``, which is what failed both blocks of ``meshgrid`` in the +corpus's mesh module. + +Only ``spread``, for the same reason ``LOCATION`` holds only the two +locators: the emitter reshapes with ``cshift``, ``eoshift``, ``pack``, +``reshape``, ``transpose`` and ``unpack`` as well, and those sites are still +the divergence this frontend deliberately keeps -- the read as a variable is +the answer a bit-exact gate has been run against, and no translation has yet +been checked against the tidier one. A name moves here when one is. +""" + STATE_QUERY = frozenset({"allocated", "associated", "present", "merge"}) """Answer about a variable's status rather than its value. @@ -177,5 +210,5 @@ else's missing library. """ -ALL = ELEMENTAL | TRANSFORMATIONAL | STATE_QUERY +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 508715d..18fc414 100644 --- a/src/recast/fortran/rwset.py +++ b/src/recast/fortran/rwset.py @@ -82,6 +82,15 @@ class Scope: chars: frozenset[str] = frozenset() """Character-typed symbols. ``write(buf, ...)`` to one of these is a write.""" + file_units: frozenset[str] = frozenset() + """Unit variables an OPEN in this body connects to a file. + + ``interface.file_units``. A WRITE to one of them is translated -- the + records go in the file -- so it reads its item list; a WRITE anywhere + else is the log stub, which reads nothing. The two sides have to draw the + line in the same place or every such block disagrees. + """ + semantics: Semantics | None = None """Type and shape answers, for the questions dispatch needs. @@ -139,6 +148,14 @@ def scope_for( # counts it as a call on the other side, so the two sides disagreed on # every block that calls a host-associated procedure. subs = {s["name"]: s for s in record["subprograms"]} + # Host association: inside a host, and inside its other internal + # procedures, the host's internals shadow any same-named procedure -- + # two hosts may each contain a ``func``, and the bare-name table above + # kept whichever came last, with the other host's variables. + scope_host = sub.get("host") or sub["name"] + for s in record["subprograms"]: + if s.get("host") == scope_host: + subs[s["name"]] = s # An explicit interface is the shape a procedure dummy calls through: # ``call func(x, val)`` binds its actuals the way a call to a known # procedure does, or ``val`` is counted read where the callee wrote it. @@ -147,20 +164,39 @@ def scope_for( ranks: dict[str, int] = {} chars: set[str] = set() + # Outermost first, so the innermost declaration is the one that stands: + # cpoly's local array ``pi`` shadows the module parameter ``pi``, and with + # the module's entries written last the local came out rank 0 -- which + # made ``pi(i) = opi(i)`` look like a statement-function definition and + # dropped the write. An internal procedure sees its host's dummies and + # locals between the module's and its own (host association). + host = next( + (s for s in record["subprograms"] if s["name"] == sub.get("host") and not s.get("host")), + None, + ) declared: list[dict[str, Any]] = [ - *sub["args"], - *sub["locals"], - *sub["local_parameters"], - *record["module_state"], *record["module_parameters"], + *record["module_state"], + *((*host["args"], *host["locals"], *host["local_parameters"]) if host else ()), + *sub["local_parameters"], + *sub["locals"], + *sub["args"], ] for entry in declared: name = entry["name"] ranks[name] = len(entry.get("dims") or []) if entry.get("dtype") == "str": chars.add(name) + else: + chars.discard(name) if sub["result"] is not None: ranks.setdefault(sub["result"], len(sub["result_dims"] or [])) + # ``character(len=n) function str(i)`` writes its result the way any + # other character variable is written -- ``write(str, '(i0)') i`` -- + # and the result variable is declared by the function statement + # rather than by a declaration this loop walked. + if sub.get("result_dtype") == "str": + chars.add(sub["result"]) interfaces = record.get("interfaces") or {} dummy_procedures = { @@ -175,6 +211,7 @@ def scope_for( generics=dict(record["generics"]), ranks=ranks, chars=frozenset(chars), + file_units=frozenset(sub.get("file_units") or ()), semantics=for_subprogram(record, sub_name, companions=companions), externals=dict(externals or {}), ) @@ -259,7 +296,13 @@ def expr_reads(node: Any, scope: Scope) -> set[str]: return reads if isinstance( - node, (f03.Part_Ref, f03.Intrinsic_Function_Reference, f03.Structure_Constructor) + node, + ( + f03.Part_Ref, + f03.Function_Reference, + f03.Intrinsic_Function_Reference, + f03.Structure_Constructor, + ), ): fname = str(node.children[0]).lower() if node.children[1] is not None: @@ -269,6 +312,7 @@ def expr_reads(node: Any, scope: Scope) -> set[str]: items = _without_kind_argument(fname, items) for item in items: reads |= expr_reads(item, 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 # bare-Name branch applies. zm_conv declares `gamma(pcols,pver)`, @@ -290,6 +334,15 @@ def expr_reads(node: Any, scope: Scope) -> set[str]: if isinstance(node, (f03.Actual_Arg_Spec, f03.Component_Spec)): return expr_reads(node.children[1], scope) # the keyword is not a read + if isinstance(node, f03.Ac_Implied_Do_Control): + # ``(expr, i = 1, n)``: the bounds are read; ``i`` is the constructor's + # own counter, written by it -- ``rwset`` records that -- and read only + # where the value expression mentions it, which is how the emitted + # comprehension ``[... for i in range(1, n + 1)]`` reads too. + for bound in node.children[1]: + reads |= expr_reads(bound, scope) + return reads + if isinstance(node, f03.Data_Ref): # The root object is the read; component names are attributes of it, # which the target side spells the same way and also does not count. @@ -304,6 +357,24 @@ def expr_reads(node: Any, scope: Scope) -> set[str]: return reads +def host_reads(name: str, scope: Scope) -> set[str]: + """The host variables a call to internal procedure ``name`` reads. + + An internal procedure uses its host's variables without naming them in + the call, and the translation passes exactly those as trailing actuals + (``interface._host_associate`` lists them as ``host_vars``). Reads on + that side with no counterpart here failed every block of ``rpoly`` that + called one of its helpers. A declared variable of the same name is data, + not a call, and reads nothing of anyone's. + """ + if scope.ranks.get(name, 0) > 0 or name in scope.alias_dims: + return set() + callee = scope.subprograms.get(name) + if callee is None: + return set() + return {str(v).lower() for v in callee.get("host_vars") or ()} + + def _resolve_generic(name: str, actuals: list[Any], scope: Scope) -> str | None: """The specific procedure a generic call dispatches to, or ``None``. @@ -343,19 +414,65 @@ def _bind_actuals(callee: dict[str, Any], items: list[Any]) -> list[Any]: return bound +IO_OUTPUT_SPECS = frozenset({"IOSTAT", "IOMSG", "SIZE", "NEWUNIT"}) +"""I/O specifiers that write a variable instead of reading one. + +An I/O statement is otherwise all reads: the unit, the file name, the format. +These four are where the statement puts something, and a translation that +dropped them would leave the variable at whatever it held -- which is the +whole reason READ and INQUIRE are translated rather than stubbed. +""" + +IO_SPEC_CLASSES = ( + f03.Connect_Spec, + f03.Close_Spec, + f03.Position_Spec, + f03.Flush_Spec, + f03.Inquire_Spec, + f03.Io_Control_Spec, +) + + +def _io_specifiers(stmt: Any) -> list[tuple[str | None, Any]]: + """``[(KEYWORD or None, value), ...]`` of an I/O statement's specifiers.""" + found = [] + for spec in walk(stmt, IO_SPEC_CLASSES): + keyword, value = spec.children + found.append((str(keyword).upper() if keyword is not None else None, value)) + return found + + +def _unit_position(specifiers: list[tuple[str | None, Any]]) -> int: + """Which specifier says what the statement is connected to. + + ``UNIT=`` if it is spelled, otherwise the first positional one -- the + order Fortran fixes for the un-keyworded form. ``-1`` when there is + neither, so every specifier is read. + """ + for at, (keyword, _value) in enumerate(specifiers): + if keyword == "UNIT": + return at + for at, (keyword, _value) in enumerate(specifiers): + if keyword is None: + return at + return -1 + + def rwset(node: Any, scope: Scope) -> tuple[set[str], set[str]]: """``(reads, writes)`` for one statement or construct.""" reads: set[str] = set() writes: set[str] = set() def _write_actual(actual: Any) -> None: - """Record an out-argument, the way the pipeline this came from did. - - Differs from ``write_target`` on a derived-type actual: this counts the - *component* name as a read as well, where the assignment path does not. - The two disagree, and this repository keeps the disagreement rather - than resolving it, because the pipeline's answers are the ones a - bit-exact gate has been run against and this one has not. + """Record an out-argument: the root is written, its subscripts read. + + A derived-type actual -- ``sdat%t`` to an intent(inout) dummy -- is + a write of ``sdat``; the component is an attribute of it, not a + symbol, which is what the assignment path (``write_target``) and the + target side both say. The pipeline this came from counted the + component name as a read here as well, and that disagreement failed + every block that passes a structure component to a callee (SLSQP's + ``slsqpb(..., sdat%t, sdat%f0, ...)``), so the tidier answer stands. """ if isinstance(actual, f03.Name): writes.add(str(actual).lower()) @@ -388,6 +505,20 @@ def write_target(target: Any) -> None: for child in target.children[1:]: reads.update(expr_reads(child, scope)) + def io_output(value: Any) -> None: + """Where an I/O statement puts an answer. ``ERR=``/``END=`` name a + statement label instead: control flow, and nothing to write.""" + if isinstance(value, (f03.Name, f03.Part_Ref, f03.Data_Ref)): + write_target(value) + + def io_specifier_rwset(container: Any) -> None: + """An I/O statement's specifiers: the output ones write, the rest read.""" + for keyword, value in _io_specifiers(container): + if keyword in IO_OUTPUT_SPECS: + io_output(value) + else: + reads.update(expr_reads(value, scope)) + def call(stmt: Any) -> None: name = str(stmt.children[0]).lower() items = list(stmt.children[1].children) if stmt.children[1] is not None else [] @@ -457,6 +588,12 @@ def _agrees(x: dict[str, Any]) -> bool: external = fitting[0] out_positions = set(external.get("out_positions", [])) if external else set() buffers = set(external.get("buffer_positions", [])) if external else set() + # A position the callee reads as well as writes -- an INOUT dummy, + # or a caller-buffer OUT the translation passes in and unpacks -- + # is a read here too. A sibling's table names those positions + # explicitly (``read_positions``, which includes its INOUT + # actuals); with no such list, a buffer OUT is still read (#38) + # and every non-OUT position is. read_positions = ( set(external["read_positions"]) if external and "read_positions" in external @@ -485,6 +622,12 @@ def _agrees(x: dict[str, Any]) -> bool: reads.update(expr_reads(actual, scope)) return + # Host association: the callee's host variables travel as trailing + # actuals in the translation, so they are reads of this statement, + # and the ones the callee changes come back as unpack targets, so + # they are writes of it. + reads.update(str(v).lower() for v in callee.get("host_vars") or ()) + writes.update(str(v).lower() for v in callee.get("host_writes") or ()) for formal, actual in zip(callee["args"], actuals, strict=False): if actual is None: continue @@ -500,6 +643,29 @@ def _agrees(x: dict[str, Any]) -> bool: if formal.get("optional") and formal["intent"] == "OUT": hands_on_presence(actual) + def function_writes(value: Any) -> None: + """A function reference's OUT/INOUT actuals are written too. + + ``alpha = linmin(line, ..., ldat%a, ...)`` changes ``line`` and + ``ldat`` as surely as a CALL would; the translation hands them back + beside the result and unpacks them (``function_outputs``), so both + sides count the write. The reference has to be the whole right-hand + side -- inside a larger expression the translation refuses it, and + a refused block is not compared. + """ + if not isinstance(value, (f03.Part_Ref, f03.Function_Reference)): + return + name = str(value.children[0]).lower() + if scope.ranks.get(name, 0) > 0: + return + callee = scope.subprograms.get(name) + if callee is None or callee.get("kind") != "function": + return + items = list(value.children[1].children) if value.children[1] is not None else [] + 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) + def visit(stmt: Any) -> None: if isinstance(stmt, f08.Block_Construct): # A named block is a scope wrapper around ordinary statements. @@ -536,6 +702,7 @@ def visit(stmt: Any) -> None: return # a statement-function definition, not dataflow write_target(lhs) reads.update(expr_reads(rhs, scope)) + function_writes(rhs) elif isinstance(stmt, f03.If_Stmt): reads.update(expr_reads(stmt.children[0], scope)) @@ -593,14 +760,92 @@ def visit(stmt: Any) -> None: writes.add(str(obj).lower()) elif isinstance(stmt, f03.Write_Stmt): - # A write to a log unit has no dataflow. An *internal* write, whose - # unit is a character variable, writes that variable. + # An *internal* write, whose unit is a character variable, writes + # that variable -- and reads its format, which can be a dummy + # argument carrying one, or an expression built from one. An + # external one writes a file rather than a variable, but a write + # to a unit this body connected to a file still *reads* every item + # in its list, its unit and its format, and the translation spells + # those reads: the record has to be built out of something. A + # write anywhere else -- ``*``, a bare unit number, a unit the + # caller connected -- is a log, and reads nothing on either side. + # + # The format is read through ``expr_reads`` rather than by taking + # every remaining name: ``write(s, "(f0." // str_int(n) // ")") r`` + # reads ``n``, and the call to ``str_int`` is control flow, which + # is exactly the distinction ``expr_reads`` already draws. Counting + # the bare names instead reported the callee as a read the emitted + # 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: writes.add(units[0]) + specifiers = _io_specifiers(control) + for at, (keyword, value) in enumerate(specifiers): + if at == _unit_position(specifiers): + continue # the internal unit itself: written, not read + if keyword in IO_OUTPUT_SPECS: + io_output(value) # IOSTAT= puts an answer somewhere + else: + reads.update(expr_reads(value, scope)) if items is not None: reads.update(expr_reads(items, scope)) + elif units and units[0] in scope.file_units: + io_specifier_rwset(control) + if items is not None: + reads.update(expr_reads(items, scope)) + + elif isinstance(stmt, f03.Read_Stmt): + # Every item in the list is a write: that is what a READ is for, + # and the reason it is translated rather than stubbed. An item + # that is a whole array or an array section is also a *read* of + # itself, because its extent is what decides how many values the + # statement consumes -- which is how the translation spells it. + control, _label, items = stmt.children + io_specifier_rwset(control) + for item in items.children if hasattr(items, "children") else [items]: + if item is None: + continue + if not isinstance(item, (f03.Name, f03.Part_Ref, f03.Data_Ref)): + # An implied-do, which the emitter refuses: every name in + # it may be written, and under-reporting a write is the + # one direction this analysis is not allowed to err in. + writes.update(str(n).lower() for n in walk(item, f03.Name)) + continue + write_target(item) + if isinstance(item, f03.Name): + if scope.ranks.get(str(item).lower(), 0) > 0: + reads.add(str(item).lower()) + elif isinstance(item, f03.Part_Ref) and walk(item, f03.Subscript_Triplet): + reads.add(str(item.children[0]).lower()) + + elif isinstance(stmt, f03.Inquire_Stmt): + # UNIT= and FILE= say what is being asked about; every other + # specifier is somewhere to put the answer. + for keyword, value in _io_specifiers(stmt): + if keyword in ("UNIT", "FILE"): + reads.update(expr_reads(value, scope)) + else: + io_output(value) + + elif isinstance( + stmt, + ( + f03.Open_Stmt, + f03.Close_Stmt, + f03.Rewind_Stmt, + f03.Backspace_Stmt, + f03.Endfile_Stmt, + f03.Flush_Stmt, + ), + ): + # NEWUNIT= and IOSTAT= are writes; the rest of a connection's + # specifiers are reads. The bare forms (``rewind u``) carry no + # specifier list at all and fall to the conservative reading. + if _io_specifiers(stmt): + io_specifier_rwset(stmt) + else: + reads.update(expr_reads(stmt, scope)) elif isinstance(stmt, f03.Pointer_Assignment_Stmt): target, _, rhs = stmt.children @@ -666,6 +911,11 @@ def visit(stmt: Any) -> None: reads.update(expr_reads(stmt, scope)) visit(node) + # An array constructor's implied-do writes its counter, wherever in the + # block the constructor stands; the emitted comprehension binds the same + # name as its loop target. + for control in walk(node, f03.Ac_Implied_Do_Control): + writes.add(str(control.children[0]).lower()) return reads, writes diff --git a/src/recast/fortran/semantics.py b/src/recast/fortran/semantics.py index 87a4cec..b5f0a87 100644 --- a/src/recast/fortran/semantics.py +++ b/src/recast/fortran/semantics.py @@ -35,10 +35,11 @@ """Dtypes settled enough to rule an overload out. A derived type counts too -- ``DERIVED_TYPE_MARKER`` matches it -- because -``type(cartesian2D_t)`` is exactly as decided as ``float64``. What is *not* -here is the point: ``UNKNOWN_REAL_KIND(k)`` names a real whose kind this stage -could not resolve, and an unresolved kind must not be allowed to reject a -candidate it might have matched.""" +``type(cartesian2D_t)`` is exactly as decided as ``float64``, and so does +COMPLEX -- ``COMPLEX_MARKER`` matches it -- because a type with no dtype here +is still a type. What is *not* here is the point: ``UNKNOWN_REAL_KIND(k)`` +names a real whose kind this stage could not resolve, and an unresolved kind +must not be allowed to reject a candidate it might have matched.""" ARITHMETIC = frozenset({"+", "-", "*", "/", "**"}) @@ -207,6 +208,20 @@ possible without a second pass. """ +COMPLEX_MARKER = re.compile(r"(UNKNOWN\(COMPLEX\)|complex\d+)$") +"""How ``interface.dtype_of`` spells a COMPLEX whose type is decided. + +A resolved complex has a concrete dtype now -- ``complex128``/``complex64`` -- +and an unresolved *kind* stays ``UNKNOWN(COMPLEX)``; both name a decided type +even when the second carries no kind. So the *type* is as decided as +``float64`` is: no actual of any real or integer kind reaches a COMPLEX dummy, +and none of any complex kind reaches a real one. Reading a complex as +undecided is what left ``sortpairs(real, real(:,:))`` ambiguous between the +real-vector and the complex-vector overload. (An ``UNKNOWN_COMPLEX_KIND(k)`` +is deliberately *not* matched: its kind is unresolved, so like +``UNKNOWN_REAL_KIND(k)`` it must not reject a candidate it might have matched.) +""" + def _is_operator(node: Any, among: frozenset[str] | set[str]) -> bool: """Whether a child is one of these operator spellings. @@ -858,7 +873,11 @@ def _matches( def _concrete_dtype(dtype: str) -> bool: - return dtype in CONCRETE_DTYPES or bool(DERIVED_TYPE_MARKER.match(dtype)) + return ( + dtype in CONCRETE_DTYPES + or bool(DERIVED_TYPE_MARKER.match(dtype)) + or bool(COMPLEX_MARKER.match(dtype)) + ) def _dtype_match(actual: str | None, formal: str | None) -> bool: @@ -890,6 +909,14 @@ def for_subprogram( not have to know which file declared its callee. """ procedures = {s["name"]: s for s in record["subprograms"]} + # This module's own INTERFACE declarations of procedures it defines + # nowhere -- LAPACK's ``dgeev``, declared in the subroutine that calls it + # -- bind the same way a companion's do below: the interface is the whole + # statement of what calling it means. ``setdefault`` again, and before the + # companions, so a body anywhere outranks a declaration here. + for declared in (record.get("interfaces") or {}).values(): + if declared.get("kind") in ("subroutine", "function"): + procedures.setdefault(declared["name"], declared) generics = dict(record["generics"]) companion_generics: dict[str, list[str]] = {} types = dict(record["types"]) @@ -897,6 +924,16 @@ def for_subprogram( companion_state: 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 + # block and defines no body for it is the interface module a library + # is reached through -- ``use lapack, only: dgesv``. The name is in + # scope here exactly as a module procedure would be, and the + # interface is the whole statement of what calling it means, so it + # binds like one. ``setdefault``: a sibling that actually defines the + # procedure outranks another's declaration of it. + for declared in (other.get("interfaces") or {}).values(): + if declared.get("kind") in ("subroutine", "function"): + procedures.setdefault(declared["name"], declared) companion_generics.update(other["generics"]) types.update(other["types"]) parameters |= {p["name"] for p in other["module_parameters"]} diff --git a/src/recast/oracle/f2py.py b/src/recast/oracle/f2py.py index 63faf53..70a40c3 100644 --- a/src/recast/oracle/f2py.py +++ b/src/recast/oracle/f2py.py @@ -40,12 +40,21 @@ from pathlib import Path from typing import Any +from recast import references from recast.errors import ConfigError, OracleUnavailable, RecastError +from recast.fortran.intrinsics import ALL as INTRINSICS from recast.model import Facts, OracleRef, Unit from recast.plugins.executor import Executor, Job from recast.plugins.oracle import Oracle -__all__ = ["F2pyGoldenOracle", "factory", "wrappers_for"] +__all__ = [ + "F2pyGoldenOracle", + "derived_components", + "factory", + "flattened_dummies", + "unspellable", + "wrappers_for", +] FORTRAN_TYPES = { "float64": "real(8)", @@ -63,6 +72,17 @@ """Raw type spellings, no kind parameters: nothing here needs f2py's crackfortran to resolve a use-imported kind, which it cannot.""" +DERIVED = re.compile(r"UNKNOWN\(TYPE\((\w+)\)\)", re.I) +"""How the frontend spells a dummy of derived type: ``UNKNOWN(TYPE(name))``.""" + +DEFINED_ZERO = {"bool": ".false.", "str": "''"} +"""What an intent(out) dummy is set to before the call, by dtype. + +A character dummy given ``0`` is a type error the compiler rejects +("Cannot convert INTEGER(4) to CHARACTER(128)"), which cost every module +with a character output its whole reference. +""" + DEFAULT_FLAGS = "-O1 -fno-fast-math -ffp-contract=off -fcheck=bounds" """Conservative by default. The reference must round the way the production build rounds, and aggressive optimization is a second variable nobody asked @@ -241,6 +261,10 @@ def _callback_declarations( dimensions, so an extent naming one of them is renamed with it; an extent naming anything else is refused rather than resolved against the wrapper's scope, where it would mean a different variable. + + A subroutine call-back is written as a CALL and a function call-back as an + assignment, because that is how crackfortran tells the two apart, and the + dummy carries the result's type so the ``implicit none`` wrapper compiles. """ name = argument["name"] # An interface record names its interface; a signature entry carries the @@ -254,11 +278,33 @@ def _callback_declarations( f"procedure argument {name!r} carries no interface; this wrapper cannot say " "what calling it means -- wrap it by hand or drop the subprogram from the gate" ) - if interface["kind"] != "subroutine": - raise ConfigError( - f"procedure argument {name!r} is a function; this wrapper spells subroutine " - "call-backs only" - ) + result_type = None + if interface["kind"] == "function": + # A function call-back answers through its result, so there is a type + # to spell twice: on the dummy itself, because the wrapper is + # ``implicit none`` and an EXTERNAL alone leaves it untyped, and on + # the variable f2py's own call takes the result in. + result_type = FORTRAN_TYPES.get(interface.get("result_dtype")) + if result_type is None: + raise ConfigError( + f"call-back {name!r} returns dtype {interface.get('result_dtype')!r}, " + "which this wrapper cannot spell" + ) + if interface.get("result_dims"): + raise ConfigError( + f"call-back {name!r} returns an array; this wrapper spells scalar function " + "call-backs only" + ) + written = [a["name"] for a in interface["args"] if a["intent"] != "IN"] + if written: + # f2py hands a function call-back's written arguments back beside + # its result, and which comes first is a convention this wrapper + # would be inventing rather than sharing with the translation. + raise ConfigError( + f"call-back {name!r} is a function that writes argument(s) " + f"{', '.join(written)}; this wrapper spells function call-backs that only " + "read theirs" + ) spelled = {a["name"].lower(): f"cb_{name}_{a['name']}" for a in interface["args"]} sized = { token.lower() @@ -298,6 +344,15 @@ def _callback_declarations( f"!f2py {base}{dims}, {', '.join(attributes)} :: {spelled[a['name'].lower()]}" ) arguments = ", ".join(spelled[a["name"].lower()] for a in interface["args"]) + if result_type is not None: + # f2py reads a *function* call-back off an assignment whose right-hand + # side calls it -- a bare call is a subroutine to crackfortran -- and + # takes the result's type from the assigned variable, which therefore + # has to be declared before the line that assigns it. + assigned = f"cb_{name}_res" + lines.append(f"!f2py {result_type} :: {assigned}") + lines.append(f"!f2py {assigned} = {name}({arguments})") + return lines, f" {result_type}, external :: {name}" lines.append(f"!f2py call {name}({arguments})") return lines, f" external {name}" @@ -369,13 +424,474 @@ def _hide( extents: str, argument_names: list[str], parameters: dict[str, int] | None, hidden: list[str] ) -> None: """An extent naming neither an argument nor a local parameter is a hidden - integer dummy the caller supplies; recorded once, in order of first use.""" - for token in re.findall(r"[A-Za-z_]\w*", extents): - if token not in argument_names and token not in (parameters or {}): - if token not in hidden: + integer dummy the caller supplies; recorded once, in order of first use. + + An intrinsic call is not such a name. ``b(size(a))`` computes its extent + from an argument already being passed, and hiding ``size`` declared a + dummy of that name beside it -- ``integer, intent(in) :: size`` next to + ``res(size(a))`` -- which gfortran rejects twice over, as a PROCEDURE + attribute conflicting with INTENT and as a call to something not PURE. + + Neither is a name the argument list already carries in another case. + Fortran does not distinguish ``N`` from ``n``, and the extent keeps the + source's spelling while the argument names arrive lowercased from the + frontend: ``real(dp) :: mesh(N+1)`` over ``integer, intent(in) :: N`` + hid an ``N`` beside the wrapper's own ``n``, which gfortran rejects as a + duplicate formal argument -- the mesh module's three exponential-mesh + functions, and every array-valued function whose extent names an + argument in capitals. + """ + known = {name.lower() for name in argument_names} | { + name.lower() for name in (parameters or {}) + } + for token, call in re.findall(r"([A-Za-z_]\w*)\s*(\(?)", extents): + lowered = token.lower() + if call and lowered in INTRINSICS: + continue + if lowered not in known: + if lowered not in {name.lower() for name in hidden}: hidden.append(token) +def _allocatable_shim( + argument: dict[str, Any], spelled: str +) -> tuple[str, list[str], list[str], list[str]]: + """Pass an ALLOCATABLE dummy the allocatable actual Fortran requires. + + ``call loadtxt(filename, d)`` does not compile with ``d`` a plain + assumed-shape dummy -- "Actual argument for 'd' must be ALLOCATABLE" -- + and f2py has no allocatable of its own to offer, because the extent the + callee chooses is not known when it builds the array it hands back. So + the wrapper keeps its caller-side buffer and calls through a local + allocatable: the buffer's values go in, the callee's array comes back as + far as the buffer reaches, and the rest of the buffer is left defined. + + Returns ``(actual, declarations, before, after)``: what to pass at the + call site, the locals to declare, and the copies either side of the call. + Truncation is why the differential harness does not call one of these -- + an array the callee sized is not the caller's buffer, and comparing the + two would be comparing shapes nobody chose (see ``BitexactVerifier``). + """ + name = argument["name"] + local = f"{name}_alloc" + rank = len(argument.get("dims") or ()) + hands_in = argument["intent"] in ("IN", "INOUT", "UNKNOWN") + hands_back = argument["intent"] != "IN" + if not rank: + # A scalar allocatable dummy: no extent to reconcile, so the local is + # allocated from the buffer and read back whole. + declarations = [f" {spelled}, allocatable :: {local}"] + before = [f" allocate({local}, source={name})"] if hands_in else [] + after = [f" if (allocated({local})) {name} = {local}"] if hands_back else [] + return local, declarations, before, after + colons = ", ".join([":"] * rank) + fits = f"{name}_n" + declarations = [f" {spelled}, allocatable :: {local}({colons})"] + before = [f" allocate({local}, source={name})"] if hands_in else [] + if not hands_back: + return local, declarations, before, [] + declarations.append(f" integer :: {fits}({rank})") + section = ", ".join(f":{fits}({axis})" for axis in range(1, rank + 1)) + after = [ + f" {fits} = 0", + f" if (allocated({local})) {fits} = min(shape({name}), shape({local}))", + f" {name} = {DEFINED_ZERO.get(argument['dtype'], '0')}", + f" if (allocated({local})) {name}({section}) = {local}({section})", + ] + return local, declarations, before, after + + +def derived_components( + record: dict[str, Any], argument: dict[str, Any], taken: set[str] | None = None +) -> list[dict[str, Any]] | str: + """One flat scalar dummy per component of a derived-type argument, or why not. + + f2py cannot marshal a derived type, and a module whose only public + subprogram takes one -- SLSQP's ``slsqp`` carries its reverse-communication + state in ``type(slsqpb_data)`` and ``type(linmin_data)`` -- had no + reference at all, so its gate never ran. A type made of scalar + components *can* be spelled: the wrapper takes each component as a + dummy of its own, ``_``, copies them into a local + of the type before the call and back out after it, and the candidate + side does the same with the object it takes (``BitexactVerifier``, + through the plan ``flattened_dummies`` puts on the oracle's handle). + + Returned entries carry ``name`` (the flat dummy), ``component``, + ``dtype`` and ``spelled`` (the Fortran declaration type). A string is the + reason there is no such spelling: a type the record does not define, one + the module does not export (the wrapper has to ``use`` it), a component + that is an array, allocatable or pointer, or one of a dtype this wrapper + cannot spell either -- and a flat name that collides with another dummy. + """ + derived = DERIVED.match(str(argument["dtype"])) + if derived is None: + return f"dtype {argument['dtype']!r} is not a derived type" + type_name = derived.group(1).lower() + components = (record.get("types") or {}).get(type_name) + if components is None: + return f"type {type_name!r} is not defined in this module's record" + exported = {str(name).lower() for name in record.get("public_types") or ()} + if type_name not in exported: + return f"type {type_name!r} is not public, so a wrapper cannot use it" + if not components: + return f"type {type_name!r} has no components" + flat: list[dict[str, Any]] = [] + names = set(taken or ()) + for component, spec in components.items(): + if spec.get("dims") or spec.get("allocatable") or spec.get("pointer"): + return f"component {type_name}%{component} is not a scalar" + spelled = FORTRAN_TYPES.get(str(spec.get("dtype"))) + if spelled is None or spec.get("dtype") == "str": + return f"component {type_name}%{component} has dtype {spec.get('dtype')!r}" + name = f"{argument['name']}_{component}".lower() + if name in names: + return f"flat name {name!r} for {type_name}%{component} collides with another dummy" + names.add(name) + flat.append( + { + "name": name, + "component": str(component).lower(), + "dtype": str(spec["dtype"]), + "spelled": spelled, + } + ) + return flat + + +def flattened_dummies( + record: dict[str, Any], subprograms: list[str] +) -> dict[str, dict[str, dict[str, Any]]]: + """``{subprogram: {argument: {"type": name, "components": [...]}}}`` for + every derived-type dummy ``wrappers_for`` spells component by component. + + The verifier reads this off the oracle's handle to split the candidate's + own derived-type argument the same way: same flat names, same order. + Only subprograms with at least one flattened dummy appear. + """ + table = {s["name"]: s for s in record["subprograms"]} + plan: dict[str, dict[str, dict[str, Any]]] = {} + for name in subprograms: + sub = table.get(name) + if sub is None: + continue + arguments = [a for a in sub["args"] if not a.get("optional")] + taken = {str(a["name"]).lower() for a in arguments} + entries: dict[str, dict[str, Any]] = {} + for argument in arguments: + derived = DERIVED.match(str(argument["dtype"])) + if derived is None: + continue + components = derived_components(record, argument, taken) + if isinstance(components, str): + continue + taken.update(c["name"] for c in components) + entries[argument["name"]] = { + "type": derived.group(1).lower(), + "components": [{k: v for k, v in c.items() if k != "spelled"} for c in components], + } + if entries: + plan[name] = entries + return plan + + +def unexercisable(subprogram: dict[str, Any]) -> str | None: + """Why the differential cannot exercise this reference, or ``None``. + + The wrapper compiles either way; what this answers is whether calling it + means anything. Four shapes it does not: + + *A character value.* ``FORTRAN_TYPES`` spells every character dummy + ``character(len=128)`` because f2py cannot size a ``len=*`` one, so the + reference's interface is not the source's, and the harness has no draw + for a string in the first place. One character dummy is exercisable + anyway: a path an OPEN in the body *creates* (``path: "created"``). Any + name works there -- the subprogram makes the file rather than finding one + -- so the harness draws a scratch path per side and compares the files, + and the wrapper passes ``trim()`` of its padded dummy so the callee's + ``len=*`` is the length the caller chose. A path the body opens + ``STATUS='OLD'`` stays ungated: the draw would have to be a file that + already holds something, which nothing here can produce. + + *An array the callee allocates.* An ALLOCATABLE intent(out) dummy is + sized by the callee; f2py can only hand back the buffer the caller + passed, and comparing a buffer against an allocation compares two shapes + nobody chose (see ``_allocatable_shim``). + + *A LOGICAL INOUT array dummy.* f2py exposes a scalar LOGICAL INOUT as a + writable rank-0 array and marshals it through its own Python-object + conversion, so writing 0/1 for false/true is enough -- Fortran's own + truthiness test is "nonzero", the same convention already relied on when + reading a LOGICAL OUT back (see ``BitexactVerifier``'s bool comparison). + That is not true of a LOGICAL INOUT *array*: f2py hands one back as an + in-place buffer whose element size must match the compiler's native + LOGICAL storage exactly (4 bytes for the default kind), while this + harness draws LOGICAL arrays with NumPy's 1-byte ``bool_`` dtype, which + f2py rejects (``failed to initialize intent(inout) array``). Left + ungated here instead of reaching that refusal (``BitexactVerifier``'s + ``logical_inouts`` check, which draws the same scalar/array line) blocks + every other subprogram in the same unit's differential gate along with + it. + + *A function with OUT/INOUT dummies.* f2py returns a FUNCTION's result + and its OUT/INOUT dummies in one tuple, the same as a SUBROUTINE's, but + ``BitexactVerifier._paired_outputs`` only ever pairs a function's single + result -- it has no side-effect leg for a function to fall into the way + a subroutine's OUT/INOUT dummies do. Left ungated here instead of + reaching that refusal (``BitexactVerifier._compare_subprogram``'s own, + matching check) blocks every other subprogram in the same unit's + differential gate along with it. + + Named rather than silently skipped: the verifier counts an uncompared + public subprogram as silence unless the oracle says why, which is what + ``OracleRef.handle["ungated"]`` carries. The flat oracle answers the same + question in ``recast.oracle.flat.unspellable``; the two differ because + what each can build differs. + """ + for argument in subprogram["args"]: + if argument.get("optional"): + continue # dropped from both calls, so it decides nothing here + if str(argument["dtype"]) == "str": + if argument.get("path") == "created": + continue + if argument.get("path") == "existing": + return ( + f"{argument['name']}: names a file the body opens STATUS='OLD', " + "which no generated draw can put there" + ) + return f"{argument['name']}: character dummy, fixed at len=128 by the wrapper" + if ( + argument.get("allocatable") + and argument.get("dims") + and argument["intent"] in ("OUT", "INOUT") + ): + return f"{argument['name']}: allocatable array the callee sizes" + if ( + argument["intent"] == "INOUT" + and str(argument["dtype"]) == "bool" + and argument.get("dims") + ): + return ( + f"{argument['name']}: LOGICAL INOUT array dummy, f2py's in-place buffer " + "requires the compiler's native LOGICAL element size, which this harness's " + "1-byte bool draw does not provide" + ) + if subprogram["kind"] == "function" and str(subprogram.get("result_dtype")) == "str": + return "character result, fixed at len=128 by the wrapper" + if subprogram["kind"] == "function": + outs_required = [ + argument["name"] + for argument in subprogram["args"] + if argument["intent"] in ("OUT", "INOUT") and not argument.get("optional") + ] + if outs_required: + return ( + "declares OUT/INOUT dummy argument(s) " + f"{', '.join(outs_required)}; this verifier cannot pair both its " + "result and side effects" + ) + return None + + +INTERFACE_BLOCK = re.compile( + r"^[ \t]*interface\b.*?^[ \t]*end[ \t]*interface\b", re.I | re.M | re.S +) +"""An INTERFACE block, for the text scan below: what it holds is declared, +not defined, which is the whole distinction ``undefined_externals`` draws.""" + +SUBPROGRAM_DEFINITION = re.compile( + r"^[^!\n]*?\b(?:subroutine|function)\s+([A-Za-z_]\w*)", re.I | re.M +) +"""A line that opens (or closes) a subprogram definition. Deliberately loose: +this only ever *suppresses* a stub, so a name too many costs a build the +diagnosis it already gives today, and a name too few costs a duplicate symbol.""" + + +def build_records(facts: Facts) -> list[dict[str, Any]]: + """Every interface record the reference build compiles from source. + + The unit's own, its companions', and the companions' own dependencies -- + the same three groups ``companion_sources`` hands the compiler, because + the question here is what that build defines. + """ + records = [facts.interface] + for group in ("companions", "companion_dependencies"): + for entry in facts.provenance.get(group) or []: + record = entry.get("record") if isinstance(entry, dict) else None + if isinstance(record, dict) and record.get("subprograms") is not None: + records.append(record) + return records + + +def undefined_externals(records: list[dict[str, Any]], extras: list[Path]) -> list[str]: + """Procedures the build declares an INTERFACE for and defines nowhere. + + ``use lapack, only: dgesv`` names a module whose whole content is + interface blocks: the bodies are in a compiled library the original + program linked, and the reference build links nothing but the sources + staged for it. The declarations are real -- they are what the compiler + checked the call against -- and the definitions are absent, so the + extension links with ``dgesv_`` undefined and the *import* fails, taking + every subprogram in the module with it, including the ones that never go + near LAPACK. + + Named here so the build can answer for them. ``recast.references`` holds a + reference implementation for a few, and those get a body that computes -- + the same one the translation gets, so the call rounds alike on both sides. + For the rest ``unresolved_stubs`` gives a body that refuses, and + ``reaching`` says which subprograms must not be exercised because they + would reach one. + + ``extras`` are scanned as text rather than as records: a source the + operator added from outside the tree may be the very definition this is + looking for, and stubbing a name that build already defines is a + duplicate symbol where there was a working reference. + """ + declared: set[str] = set() + defined: set[str] = set() + for record in records: + defined |= {str(s["name"]).lower() for s in record["subprograms"]} + for entry in (record.get("interfaces") or {}).values(): + if isinstance(entry, dict) and entry.get("kind") in ("subroutine", "function"): + declared.add(str(entry["name"]).lower()) + for extra in extras: + try: + text = extra.read_text(errors="replace") + except OSError: + continue + defined |= { + match.group(1).lower() + for match in SUBPROGRAM_DEFINITION.finditer(INTERFACE_BLOCK.sub("", text)) + } + return sorted(declared - defined) + + +def reaching(records: list[dict[str, Any]], targets: set[str]) -> dict[str, str]: + """Subprogram name -> the undefined procedure it reaches, directly or not. + + ``spline3`` calls ``spline3pars``, which calls ``dgesv``; neither can be + run against a reference whose ``dgesv`` is a refusal, and only this + closure says so about the first one. + """ + edges: dict[str, set[str]] = {} + for record in records: + for subprogram in record["subprograms"]: + name = str(subprogram["name"]).lower() + edges.setdefault(name, set()).update( + str(callee).lower() + for callee in (*subprogram["calls"], *subprogram.get("external_calls", ())) + ) + found: dict[str, str] = {} + for name in edges: + seen: set[str] = set() + pending = [name] + while pending: + current = pending.pop() + for callee in sorted(edges.get(current, ())): + if callee in targets: + found.setdefault(name, callee) + pending = [] + break + if callee not in seen: + seen.add(callee) + pending.append(callee) + return found + + +def unresolved_stubs(names: list[str]) -> str: + """A definition for each undefined external: one that refuses. + + The reference exists to say what the original program computes, and for a + call into a library this build does not have it cannot say. A body that + stops is the honest form of that: the symbol resolves, so the extension + loads and the subprograms that never reach the library are compared as + usual, and anything that does reach it stops where the missing library is + rather than returning a number nobody computed. Nothing should reach one + -- ``reaching`` leaves every caller ungated -- and if something does, this + says which name was missing. + + No argument list: a Fortran external is resolved by name, and the callers + were compiled against the interface the tree declared, not against this. + """ + lines = [ + "! Machine-generated by recast for the reference build.", + "! Procedures this build declares an INTERFACE for and defines nowhere:", + "! the library the original program linked is not part of it.", + ] + for name in names: + lines.extend( + [ + f"subroutine {name}()", + f' error stop "recast reference build: {name} has no definition in this build"', + f"end subroutine {name}", + ] + ) + return "\n".join(lines) + "\n" + + +def _generic_reach(record: dict[str, Any]) -> set[str]: + """The specific procedures a public generic name reaches. + + ``record["public"]`` names the module's public entities and + ``record["generics"]`` maps each generic to its specifics; a specific of a + public generic is callable from outside the module even though its own + name is private, which is the whole reason ``wrappers_for`` calls one + through the generic. + """ + public = {str(name).lower() for name in record.get("public") or ()} + return { + specific + for generic, specifics in (record.get("generics") or {}).items() + if str(generic).lower() in public + for specific in specifics + } + + +def unspellable( + record: dict[str, Any], + names: list[str], + *, + parameters: dict[str, Any] | None = None, + dims_override: dict[str, str] | None = None, +) -> dict[str, str]: + """Subprogram name -> why ``wrappers_for`` cannot write its wrapper. + + Answered by writing each one alone. What cannot be spelled -- a dtype with + no Fortran form (COMPLEX has none here), a call-back whose interface the + frontend did not resolve, an array result of deferred extent -- is decided + in ``wrappers_for``, and a second copy of those rules here would be a + second implementation to disagree with the first. The reason is the + wrapper's own refusal, less the name it already prefixes. + + A public subprogram in this table is left *ungated* rather than failing + the build: the reference is compiled for the rest of the module, and the + verdict carries the name and the reason (``OracleRef.handle["ungated"]``), + which is what the flat oracle already does (``recast.oracle.flat.unspellable``) + and what ``unexercisable`` does for a wrapper that compiles but cannot be + called. Failing instead cost every real-valued subprogram of a module its + reference for the sake of one COMPLEX overload nobody required, and the + coverage policy still refuses to count an ungated subprogram verified. + """ + refused: dict[str, str] = {} + for name in names: + try: + wrappers_for(record, [name], parameters=parameters, dims_override=dims_override) + except ConfigError as error: + reason = str(error) + prefix = f"{name}: " + refused[name] = reason[len(prefix) :] if reason.startswith(prefix) else reason + return refused + + +def _wrappable(record: dict[str, Any], name: str) -> bool: + """Whether ``wrappers_for`` can write this subprogram's wrapper; see + ``unspellable`` for what decides it.""" + try: + wrappers_for(record, [name]) + except ConfigError: + return False + return True + + def wrappers_for( record: dict[str, Any], subprograms: list[str], @@ -420,11 +936,23 @@ def wrappers_for( call_name = generic_of.get(name, name) arguments = [a for a in sub["args"] if not a.get("optional")] argument_names = [a["name"] for a in arguments] + # What the call passes, which is the dummy's own name except where an + # ALLOCATABLE dummy forces a local allocatable actual. + actuals = list(argument_names) + before: list[str] = [] + after: list[str] = [] declarations = [] hidden: list[str] = [] converted: list[str] = [] # scalar LOGICAL INOUTs, through an integer + # A derived-type dummy is spelled component by component: the + # wrapper's dummies are the flat scalars, in the argument's place, + # and the call passes a local of the type they were copied into. + dummies: list[str] = [] + used_types: list[str] = [] + taken = {str(a["name"]).lower() for a in arguments} for argument in arguments: if argument["dtype"] == "PROCEDURE": + dummies.append(argument["name"]) try: directives, external = _callback_declarations(argument, interfaces) except ConfigError as error: @@ -433,6 +961,42 @@ def wrappers_for( declarations.extend(directives) continue spelled = FORTRAN_TYPES.get(argument["dtype"]) + derived = DERIVED.match(str(argument["dtype"])) if spelled is None else None + if derived is not None: + components = ( + derived_components(record, argument, taken) + if is_module + else "a derived type of a file of bare subprograms cannot be used" + ) + if isinstance(components, str): + raise ConfigError( + f"{name}: argument {argument['name']!r} has dtype " + f"{argument['dtype']!r}, which this wrapper cannot spell " + f"({components}); wrap it by hand or drop the subprogram from the gate" + ) + type_name = derived.group(1).lower() + if type_name not in used_types: + used_types.append(type_name) + taken.update(c["name"] for c in components) + hands_in = argument["intent"] in ("IN", "INOUT", "UNKNOWN") + hands_back = argument["intent"] != "IN" + intent = "in" if not hands_back else "in out" + declarations.append(f" type({type_name}) :: {argument['name']}") + for component in components: + dummies.append(component["name"]) + declarations.append( + f" {component['spelled']}, intent({intent}) :: {component['name']}" + ) + if hands_in: + before.append( + f" {argument['name']}%{component['component']} = {component['name']}" + ) + if hands_back: + after.append( + f" {component['name']} = {argument['name']}%{component['component']}" + ) + continue + dummies.append(argument["name"]) if spelled is None: raise ConfigError( f"{name}: argument {argument['name']!r} has dtype " @@ -449,13 +1013,24 @@ def wrappers_for( declarations.append(f" logical :: {argument['name']}_l") converted.append(argument["name"]) continue - if _passed_buffer(argument): + if _passed_buffer(argument) or ( + argument["intent"] == "OUT" and argument.get("allocatable") and argument.get("dims") + ): # A caller-buffer OUT array of no declared extent (``fe(*)``, - # PCHIP's evaluators): f2py cannot allocate what it cannot - # size, so the caller's storage goes in and is written in - # place, on both sides -- the gate hands the same buffer to - # the candidate and reads this one back after the call. - intent = "inout" + # PCHIP's evaluators), or an OUT allocatable array: the array + # is the caller's storage on both sides, so the reference takes + # it the way the candidate does -- an argument, updated in + # place. Spelling it intent(out) asks f2py to allocate a result + # whose extent the wrapper never states -- ``dy(*)``, + # ``x2(:, :)`` -- and every call died on "must have defined + # dimensions but got (-1, -1)" (``meshgrid`` of the mesh + # module, ``dcopy`` of SLSQP). + # + # An allocatable OUT array is shimmed through a local + # allocatable (see ``_allocatable_shim``); its own dummy is the + # caller's buffer, spelled ``in out`` as the shimmed dummies + # are. A plain caller buffer (``dy(*)``) keeps ``inout``. + intent = "in out" if argument.get("allocatable") else "inout" dims = "" override = (dims_override or {}).get(argument["name"]) if override and argument.get("dims"): @@ -471,6 +1046,21 @@ def wrappers_for( # is the mix ``(incfd, :)`` that spelling ``*`` as ``:`` made. dims = "(" + ", ".join(_extent(d) for d in argument["dims"]) + ")" declarations.append(f" {spelled}, intent({intent}) :: {argument['name']}{dims}") + if argument["dtype"] == "str" and argument.get("path"): + # The wrapper's dummy is a fixed width and the callee's is + # ``len=*``: passed straight through, the callee would see 128 + # characters whatever the caller wrote. TRIM restores the + # caller's own length, which is what the source's callers pass. + # Only where the harness supplies the value -- a path it draws + # -- because everywhere else the subprogram is ungated and the + # actual would be changing a wrapper nothing calls. + actuals[argument_names.index(argument["name"])] = f"trim({argument['name']})" + if argument.get("allocatable"): + actual, locals_, opening, closing = _allocatable_shim(argument, spelled) + actuals[argument_names.index(argument["name"])] = actual + declarations.extend(locals_) + before.extend(opening) + after.extend(closing) # An intent(out) dummy is undefined on entry, and a subprogram that # returns early -- a guard rejecting its own arguments -- never # assigns it. What f2py then hands back is whatever the buffer it @@ -487,18 +1077,23 @@ def wrappers_for( # 0 against the candidate's generated value. An assumed-size dummy, # which is always a buffer, cannot be assigned whole anyway. defined = [ - f" {a['name']} = {'.false.' if a['dtype'] == 'bool' else '0'}" + f" {a['name']} = {DEFINED_ZERO.get(a['dtype'], '0')}" for a in arguments if a["intent"] == "OUT" and a["dtype"] != "PROCEDURE" and not a.get("buffer") ] wrapper = f"w_{name}" names.append(wrapper) # A scalar LOGICAL INOUT: the integer in, the logical to the callee, - # the integer out again. - actuals = [f"{a}_l" if a in converted else a for a in argument_names] - before = [f" {a}_l = ({a} /= 0)" for a in converted] - after = [f" {a} = merge(1, 0, {a}_l)" for a in converted] - use_line = [f" use {module}, only: {call_name}"] if is_module else [] + # the integer out again. The conversions join the copies the + # derived-type and allocatable dummies already queued either side of + # the call, and a converted argument's actual is its logical local. + for a in converted: + actuals[argument_names.index(a)] = f"{a}_l" + before += [f" {a}_l = ({a} /= 0)" for a in converted] + after += [f" {a} = merge(1, 0, {a}_l)" for a in converted] + use_line = ( + [f" use {module}, only: {', '.join([call_name, *used_types])}"] if is_module else [] + ) external_line = [] if is_module else [f" external {call_name}"] result_dims = sub.get("result_dims") or [] if sub["kind"] == "function" else [] if result_dims: @@ -518,7 +1113,7 @@ def wrappers_for( _hide(extent, argument_names, parameters, hidden) declarations += [f" integer, intent(in) :: {token}" for token in hidden] pieces += [ - f"subroutine {wrapper}({', '.join([*argument_names, *hidden, 'res'])})", + f"subroutine {wrapper}({', '.join([*dummies, *hidden, 'res'])})", *use_line, " implicit none", *parameter_lines, @@ -535,7 +1130,7 @@ def wrappers_for( result = FORTRAN_TYPES.get(sub["result_dtype"], "real(8)") declarations += [f" integer, intent(in) :: {token}" for token in hidden] pieces += [ - f"function {wrapper}({', '.join([*argument_names, *hidden])}) result(res)", + f"function {wrapper}({', '.join([*dummies, *hidden])}) result(res)", *use_line, " implicit none", *parameter_lines, @@ -551,7 +1146,7 @@ def wrappers_for( else: declarations += [f" integer, intent(in) :: {token}" for token in hidden] pieces += [ - f"subroutine {wrapper}({', '.join([*argument_names, *hidden])})", + f"subroutine {wrapper}({', '.join([*dummies, *hidden])})", *use_line, " implicit none", *parameter_lines, @@ -576,6 +1171,12 @@ def companion_sources(facts: Facts, root: Path) -> list[Path]: resolved those siblings -- ``Facts.provenance['companions']`` names them -- so the reference build asks the facts rather than the operator. + ``provenance['companion_dependencies']`` is the rest of that closure: what + the companions themselves ``use``. None of it is visible in this unit, so + none of it is a companion, but a companion is compiled from source here + and a compiler wants the ``.mod`` under it too -- without them the build + stops at "cannot open module file" on a file the unit never named. + ``config['extra_sources']`` stays what it always was: files from outside the tree, which nothing in the tree can name. Ordering is a topological sort over the companions' own ``use`` statements, because a Fortran @@ -586,8 +1187,11 @@ def companion_sources(facts: Facts, root: Path) -> list[Path]: companions = facts.provenance.get("companions") or [] if not isinstance(companions, list): raise ConfigError("Facts.provenance['companions'] must be a list") + dependencies = facts.provenance.get("companion_dependencies") or [] + if not isinstance(dependencies, list): + raise ConfigError("Facts.provenance['companion_dependencies'] must be a list") by_module: dict[str, tuple[dict[str, Any], Path]] = {} - for index, companion in enumerate(companions): + for index, companion in enumerate([*companions, *dependencies]): if not isinstance(companion, dict): raise ConfigError(f"companion {index} must be an object") module = str(companion.get("module", "")).lower() @@ -721,6 +1325,25 @@ def materialize( raise OracleUnavailable( f"{unit.uid}: no public subprogram to wrap; there is no reference to build" ) + # A public subprogram the wrapper cannot spell is left ungated, name + # and reason on the handle, and the reference is built for the rest. + # An operator's explicit list is different: a name they wrote is a + # name they meant, and refusing it loudly is the answer to "wrap it + # by hand or drop the subprogram from the gate". + refused: dict[str, str] = {} + if not config.get("subprograms"): + refused = unspellable( + facts.interface, + subprograms, + parameters=config.get("wrapper_parameters"), + dims_override=config.get("wrapper_dims"), + ) + subprograms = [name for name in subprograms if name not in refused] + if not subprograms: + raise OracleUnavailable( + f"{unit.uid}: no public subprogram this wrapper can spell; " + + "; ".join(f"{n} ({why})" for n, why in sorted(refused.items())) + ) wrapper_text, wrapper_names = wrappers_for( facts.interface, subprograms, @@ -742,6 +1365,29 @@ def materialize( original_sources = [*companions, *extras, source] stage = Path(tempfile.mkdtemp(prefix="f2py-stage-", dir=build)) sources, wrapper, include_args = _stage_build_inputs(stage, original_sources, wrapper_text) + # A procedure the tree declares an interface for and defines nowhere + # is a call into a library this build does not link. Left alone, the + # extension links with the symbol undefined and *importing* it fails, + # which costs the module's every other subprogram its reference too. + records = build_records(facts) + unresolved = undefined_externals(records, extras) + # A few of them recast can define rather than refuse, and defines the + # same way on the other side (``recast.references``): those are not + # blocked, because there *is* a reference for a subprogram that + # reaches one -- one that stood in for the library, which the verdict + # says. The rest keep the body that refuses, and keep disclaiming + # their callers. + substituted = references.supported(unresolved) + missing = [name for name in unresolved if name not in set(substituted)] + blocked = reaching(records, set(missing)) if missing else {} + if substituted: + supplied = Path("sources") / "recast_references.f90" + (stage / supplied).write_text(references.fortran_for(substituted)) + sources.append(supplied.as_posix()) + if missing: + stub = Path("sources") / "unresolved.f90" + (stage / stub).write_text(unresolved_stubs(missing)) + sources.append(stub.as_posix()) # fflags remains the operator's compiler-flags string. Source/include # paths never join it: NumPy splits this value internally, so appending # an original directory here would let whitespace and flag-looking @@ -847,6 +1493,35 @@ def materialize( handle={ "module": module, "wrappers": dict(zip(subprograms, wrapper_names, strict=True)), + # Derived-type dummies the wrappers spell component by + # component; the verifier splits the candidate's the same way. + "flattened": flattened_dummies(facts.interface, subprograms), + "ungated": { + **{ + s["name"]: reason + for s in facts.interface["subprograms"] + if s["name"] in set(subprograms) + and ( + reason := ( + unexercisable(s) + or ( + f"reaches {blocked[s['name']]}, which no source in this " + "build defines" + if s["name"] in blocked + else None + ) + ) + ) + is not None + }, + **refused, + }, + # What this build stood in for rather than linked. Not a + # narrowing of the comparison -- both sides ran it -- but a + # condition on it, and one the evidence has to carry: the + # numbers a subprogram reaching one of these was compared at + # are not the numbers the library would have produced. + "substituted": {name: references.reason(name) for name in substituted}, "build_dir": stage, "isolation": isolation, }, @@ -930,9 +1605,29 @@ def _subprograms(facts: Facts, config: dict[str, Any]) -> list[str]: named = config.get("subprograms") if named: return list(named) + record = facts.interface # Public only, because the wrappers `use` the module: a private # symbol is not importable and the build fails on the whole file. - return [s["name"] for s in facts.interface["subprograms"] if s.get("public", True)] + # A specific procedure of a public generic is the exception + # ``wrappers_for`` already makes -- it calls one through the generic + # name, which *is* importable -- so it is reachable too. Without it a + # module that publishes nothing but generics has no public subprogram + # at all and gets no reference: the corpus's sorting module declares + # `public sort, sortpairs, argsort` over twelve private specifics, and + # every one of them was skipped here while the wrapper stood ready to + # write it. + reached = _generic_reach(record) + return [ + s["name"] + for s in record["subprograms"] + # Reached, not exported: a public name whose interface this + # wrapper cannot spell stays in the list, because the module says + # it is part of its surface -- ``materialize`` then leaves it + # ungated by name and reason (``unspellable``) -- but one that is + # merely reachable is dropped instead: a complex-valued overload + # of a generic must not cost its ten siblings their reference. + if s.get("public", True) or (s["name"] in reached and _wrappable(record, s["name"])) + ] def factory(**_config: Any) -> F2pyGoldenOracle: diff --git a/src/recast/recipes/__init__.py b/src/recast/recipes/__init__.py index 5bf089d..84af192 100644 --- a/src/recast/recipes/__init__.py +++ b/src/recast/recipes/__init__.py @@ -10,9 +10,19 @@ from __future__ import annotations +from collections.abc import Mapping from typing import Any from recast.plugins.recipe import Recipe, Stage +from recast.transform.profiles import PROFILES + +GOLDEN_ORACLES = ("f2py-golden", "f2py-golden-flat") +"""The shipped oracles that compile the reference with ``config["fc"]``.""" + +GOLDEN_DEFAULT_FC = "gfortran" +"""The compiler those oracles build with when ``fc`` is not configured; the +oracle's own default, repeated here because the recipe has to know which +compiler its transform is matching before either plugin is instantiated.""" class TranslateRecipe(Recipe): @@ -56,12 +66,13 @@ def resolved_engine_id(self, config: dict[str, Any]) -> str | None: def stages(self, config: dict[str, Any]) -> list[Stage]: target = config.get("target", "numpy") + oracle = config.get("oracle", "f2py-golden") return [ Stage("executor", config.get("executor", "local")), Stage("frontend", config.get("frontend", "fortran")), - Stage("transform", f"translate.{target}"), + Stage("transform", f"translate.{target}", config=_lowering_of(config, oracle)), Stage("verifier", "static.rwset", gate=True), - Stage("oracle", config.get("oracle", "f2py-golden")), + Stage("oracle", oracle), Stage("verifier", "differential.bitexact", gate=True), Stage("verifier", "symbolic.notary", optional=True), Stage("store", "fs-evidence"), @@ -73,6 +84,32 @@ def validate(self, config: dict[str, Any]) -> list[str]: return [] if target in known else [f"unknown target {target!r}; expected {sorted(known)}"] +def _lowering_of(config: dict[str, Any], oracle: str) -> dict[str, Any]: + """The transform's compiler profile, when the operator has not chosen one. + + The gate compares the translation with a binary the oracle compiles, and + the profile says how that compiler lowers what it lowers observably -- + ``x**2`` as ``x*x`` under gfortran, a ``pow`` call under the transform's + own default. Left to the two plugins' separate defaults the recipe + compared an ``ifx`` lowering against a gfortran build, and a squared real + was one to two ULP from bit-exact with nothing in the source to blame. + So the recipe binds them: the oracle's ``fc`` (its default when unset), + when it names a known profile. An operator's word wins either way -- + ``compiler_semantics`` binds both stages itself and must not meet a + second, contradicting declaration here, and an explicit + ``stages..profile`` is merged over this declaration by the + runner. An oracle that is not one of the golden pair compiles with + something this recipe cannot see, and the transform keeps its default. + """ + if config.get("compiler_semantics") is not None or oracle not in GOLDEN_ORACLES: + return {} + stages = config.get("stages") + oracle_config = stages.get(oracle) if isinstance(stages, Mapping) else None + fc = oracle_config.get("fc") if isinstance(oracle_config, Mapping) else None + compiler = fc if fc is not None else GOLDEN_DEFAULT_FC + return {"profile": compiler} if compiler in PROFILES else {} + + class RefactorRecipe(Recipe): """Architectural refactoring of a monolith, gated on a pinned full-model run. diff --git a/src/recast/references.py b/src/recast/references.py new file mode 100644 index 0000000..d091452 --- /dev/null +++ b/src/recast/references.py @@ -0,0 +1,343 @@ +"""Reference implementations for library procedures no source in a build defines. + +``use lapack, only: dgesv, dgbsv`` names a module whose whole content is +interface blocks: the declarations are real -- they are what the compiler +checked the call against -- and the bodies are in a library the original +program linked. A build staged from the tree links no such library, and that +costs more than a missing symbol. The reference build cannot define ``dgesv``, +so ``undefined_externals`` gives it a body that refuses and ``reaching`` marks +every subprogram that would arrive there as one the differential holds no +reference for; the translation, meanwhile, spells the call ``_lapack.dgesv(...)`` +into a module that defines nothing of the name. ``spline3`` and ``spline3pars`` +came out of that with a passing unit and nothing compared. + +What is supplied here is the third answer: for the few procedures whose +contract is small enough to state exactly, one implementation written twice -- +once as the Fortran the reference build compiles, once as the Python the +translation calls -- statement for statement, so the two sides stand in for the +missing library the *same* way and round alike. The differential then compares +the translation of everything around the call, which is the part recast wrote, +instead of declining to compare it at all. + +Two things this is not, and both are said on the verdict (``substituted``): + +* It is not LAPACK. It is straightforward Gaussian elimination with partial + pivoting, so it answers the same question to within its own rounding, and a + build that links the real library will not reproduce these bits. A gate that + ran against this stood in for the library on both sides and says so. +* It is not a way to translate a library. Anything beyond ``SUPPORTED`` is + still declared, still undefined, and still disclaims its callers -- an + audited ``externals`` shim is what covers those, and it is the operator's to + supply. + +The pairs are kept in one file, next to each other, because the only thing that +makes them useful is that they agree; ``tests/test_references.py`` compiles the +Fortran and runs the Python over the same draws to hold them there. +""" + +from __future__ import annotations + +from collections.abc import Iterable + +__all__ = ["SUPPORTED", "fortran_for", "python_for", "reason", "supported"] + +SUPPORTED = frozenset({"dgbsv", "dgesv"}) +"""Procedures this module can stand in for, by lower-case name.""" + +REASON = ( + "no source in this build defines it; recast's own reference implementation " + "stands in for the library on both sides" +) + +_FORTRAN_HEADER = """\ +! Machine-generated by recast for the reference build. +! Reference implementations for procedures this build declares an INTERFACE +! for and defines nowhere. The same arithmetic, in the same order, is emitted +! into the translation, so both sides of the differential stand in for the +! library that is missing here in the same way. +""" + +_FORTRAN_HELPER = """\ +subroutine recast_ref_gepp(n, nrhs, a, lda, ipiv, b, ldb, info) + ! Gaussian elimination with partial pivoting: factor A and solve A*X = B, + ! both in place. The pivot search takes the first row of largest modulus, + ! the elimination runs right-looking column by column, and the right-hand + ! sides are carried along with it, so there is one traversal of the matrix + ! and the Python beside this one can be read against it line by line. + implicit none + integer, intent(in) :: n, nrhs, lda, ldb + integer, intent(out) :: ipiv(*), info + double precision, intent(inout) :: a(lda,*), b(ldb,*) + integer :: i, j, k, p + double precision :: amax, t + info = 0 + if (n < 0 .or. nrhs < 0 .or. lda < max(1, n) .or. ldb < max(1, n)) then + info = -1 + return + end if + do k = 1, n + p = k + amax = abs(a(k,k)) + do i = k + 1, n + if (abs(a(i,k)) > amax) then + amax = abs(a(i,k)) + p = i + end if + end do + ipiv(k) = p + if (a(p,k) == 0.0d0) then + info = k + return + end if + if (p /= k) then + do j = 1, n + t = a(k,j) + a(k,j) = a(p,j) + a(p,j) = t + end do + do j = 1, nrhs + t = b(k,j) + b(k,j) = b(p,j) + b(p,j) = t + end do + end if + do i = k + 1, n + a(i,k) = a(i,k) / a(k,k) + end do + do j = k + 1, n + do i = k + 1, n + a(i,j) = a(i,j) - a(i,k) * a(k,j) + end do + end do + do j = 1, nrhs + do i = k + 1, n + b(i,j) = b(i,j) - a(i,k) * b(k,j) + end do + end do + end do + do j = 1, nrhs + do i = n, 1, -1 + t = b(i,j) + do k = i + 1, n + t = t - a(i,k) * b(k,j) + end do + b(i,j) = t / a(i,i) + end do + end do +end subroutine recast_ref_gepp +""" + +_FORTRAN = { + "dgesv": """\ +subroutine dgesv(n, nrhs, a, lda, ipiv, b, ldb, info) + ! LAPACK's DGESV contract: A is overwritten by its factors, IPIV by the + ! pivot rows, B by the solution, INFO by the column a zero pivot was found + ! in (0 when there was none). + implicit none + integer, intent(in) :: n, nrhs, lda, ldb + integer, intent(out) :: ipiv(*), info + double precision, intent(inout) :: a(lda,*), b(ldb,*) + call recast_ref_gepp(n, nrhs, a, lda, ipiv, b, ldb, info) +end subroutine dgesv +""", + "dgbsv": """\ +subroutine dgbsv(n, kl, ku, nrhs, ab, ldab, ipiv, b, ldb, info) + ! LAPACK's DGBSV contract, over LAPACK's band storage: on entry A(i,j) is + ! AB(kl+ku+1+i-j, j) for the band, on exit the factors are in the same + ! place, over the wider band the pivoting fills. The band is expanded, the + ! dense factorization above is run on it, and the factors are written back: + ! the operations skipped by a banded factorization are the ones on exact + ! zeros, so this rounds where a banded one would. + implicit none + integer, intent(in) :: n, kl, ku, nrhs, ldab, ldb + integer, intent(out) :: ipiv(*), info + double precision, intent(inout) :: ab(ldab,*), b(ldb,*) + double precision, allocatable :: a(:,:) + integer :: i, j, kv + info = 0 + if (n < 0 .or. kl < 0 .or. ku < 0 .or. ldab < 2*kl + ku + 1) then + info = -1 + return + end if + kv = kl + ku + allocate(a(max(1, n), max(1, n))) + a = 0.0d0 + do j = 1, n + do i = max(1, j - ku), min(n, j + kl) + a(i,j) = ab(kv + 1 + i - j, j) + end do + end do + call recast_ref_gepp(n, nrhs, a, max(1, n), ipiv, b, ldb, info) + do j = 1, n + do i = max(1, j - kv), min(n, j + kl) + ab(kv + 1 + i - j, j) = a(i,j) + end do + end do + deallocate(a) +end subroutine dgbsv +""", +} + +_PYTHON_HELPER = '''\ +def _recast_ref_2d(x, ld, arg, routine): + """A Fortran ``(LD,*)`` dummy, as the translation hands one over. + + The emitted call passes the array the source declared, and this reads it + with the leading dimension the call states -- so the two agree only while + the array's own first extent *is* that dimension. Anything else is a + Fortran sequence association nothing here can see through, and it stops + rather than reading the wrong element. + """ + if getattr(x, "ndim", 0) != 2 or int(x.shape[0]) != int(ld): + raise SystemExit( + "%s: %s was passed with leading dimension %d and shape %r; recast's " + "reference implementation reads it as a Fortran (LD,*) array" + % (routine, arg, int(ld), getattr(x, "shape", None)) + ) + + +def _recast_ref_gepp(n, nrhs, a, ipiv, b): + """Gaussian elimination with partial pivoting; returns LAPACK's INFO. + + Statement for statement the Fortran the reference build compiles for the + same name, so that the two round alike -- every operation here is one + IEEE double add, subtract, multiply or divide, in the order the Fortran + performs it. + """ + n = int(n) + nrhs = int(nrhs) + for k in range(1, n + 1): + p = k + amax = abs(a[k - 1, k - 1]) + for i in range(k + 1, n + 1): + if abs(a[i - 1, k - 1]) > amax: + amax = abs(a[i - 1, k - 1]) + p = i + ipiv[k - 1] = p + if a[p - 1, k - 1] == 0.0: + return k + if p != k: + for j in range(1, n + 1): + t = a[k - 1, j - 1] + a[k - 1, j - 1] = a[p - 1, j - 1] + a[p - 1, j - 1] = t + for j in range(1, nrhs + 1): + t = b[k - 1, j - 1] + b[k - 1, j - 1] = b[p - 1, j - 1] + b[p - 1, j - 1] = t + for i in range(k + 1, n + 1): + a[i - 1, k - 1] = a[i - 1, k - 1] / a[k - 1, k - 1] + for j in range(k + 1, n + 1): + for i in range(k + 1, n + 1): + a[i - 1, j - 1] = a[i - 1, j - 1] - a[i - 1, k - 1] * a[k - 1, j - 1] + for j in range(1, nrhs + 1): + for i in range(k + 1, n + 1): + b[i - 1, j - 1] = b[i - 1, j - 1] - a[i - 1, k - 1] * b[k - 1, j - 1] + for j in range(1, nrhs + 1): + for i in range(n, 0, -1): + t = b[i - 1, j - 1] + for k in range(i + 1, n + 1): + t = t - a[i - 1, k - 1] * b[k - 1, j - 1] + b[i - 1, j - 1] = t / a[i - 1, i - 1] + return 0 + + +def _recast_ref_stop(routine, info): + """What a nonzero INFO has to become on this side. + + The Fortran hands INFO back through the argument and the caller decides; + a translated call cannot write an integer back into its caller's scalar, + so the one thing this can honestly do with a singular matrix is refuse, + the way a translated ERROR STOP does. The reference build is not asked + for this draw: the differential calls the candidate first and draws + again when it refuses. + """ + raise SystemExit( + "%s: recast's reference implementation found no pivot in column %d and " + "cannot hand INFO back to the translation" % (routine, int(info)) + ) +''' + +_PYTHON = { + "dgesv": '''\ +def dgesv(n, nrhs, a, lda, ipiv, b, ldb, info=0): + """DGESV(N, NRHS, A, LDA, IPIV, B, LDB, INFO), recast's own. + + ``a``, ``ipiv`` and ``b`` are updated in place, the way the Fortran + updates its dummies. ``info`` is accepted and ignored: see + ``_recast_ref_stop``. + """ + _recast_ref_2d(a, lda, "a", "dgesv") + _recast_ref_2d(b, ldb, "b", "dgesv") + status = _recast_ref_gepp(n, nrhs, a, ipiv, b) + if status != 0: + _recast_ref_stop("dgesv", status) + return status +''', + "dgbsv": '''\ +def dgbsv(n, kl, ku, nrhs, ab, ldab, ipiv, b, ldb, info=0): + """DGBSV(N, KL, KU, NRHS, AB, LDAB, IPIV, B, LDB, INFO), recast's own. + + LAPACK's band storage: A(i,j) is ``ab[kl+ku+i-j, j-1]``. The band is + expanded, factored densely and written back over the wider band the + pivoting fills, which is what the Fortran beside this does. + """ + _recast_ref_2d(ab, ldab, "ab", "dgbsv") + _recast_ref_2d(b, ldb, "b", "dgbsv") + n = int(n) + kl = int(kl) + ku = int(ku) + if n < 0 or kl < 0 or ku < 0 or int(ldab) < 2 * kl + ku + 1: + raise SystemExit( + "dgbsv: n=%d, kl=%d, ku=%d and ldab=%d are not a band this can read" + % (n, kl, ku, int(ldab)) + ) + kv = kl + ku + a = np.zeros((max(1, n), max(1, n)), dtype=np.float64) + for j in range(1, n + 1): + for i in range(max(1, j - ku), min(n, j + kl) + 1): + a[i - 1, j - 1] = ab[kv + i - j, j - 1] + status = _recast_ref_gepp(n, nrhs, a, ipiv, b) + for j in range(1, n + 1): + for i in range(max(1, j - kv), min(n, j + kl) + 1): + ab[kv + i - j, j - 1] = a[i - 1, j - 1] + if status != 0: + _recast_ref_stop("dgbsv", status) + return status +''', +} + + +def supported(names: Iterable[str]) -> list[str]: + """Those of ``names`` this module has a reference implementation for.""" + return sorted({str(name).lower() for name in names} & SUPPORTED) + + +def reason(name: str) -> str: + """What the evidence says about a name this stood in for.""" + return f"{name}: {REASON}" + + +def fortran_for(names: Iterable[str]) -> str: + """One compilable file defining every requested name. + + Free-standing subprograms, not a module: the callers were compiled + against the interface their own tree declared, and what they need + resolved is the external symbol of that name. + """ + wanted = supported(names) + if not wanted: + return "" + return "\n".join([_FORTRAN_HEADER, _FORTRAN_HELPER, *(_FORTRAN[name] for name in wanted)]) + + +def python_for(names: Iterable[str]) -> list[str]: + """The same definitions as emitted-module lines, helper first.""" + wanted = supported(names) + if not wanted: + return [] + lines: list[str] = [] + for text in (_PYTHON_HELPER, *(_PYTHON[name] for name in wanted)): + lines.extend(text.splitlines()) + lines.append("") + return lines diff --git a/src/recast/transform/numpy/constants.py b/src/recast/transform/numpy/constants.py index e47b5a7..b96b1e8 100644 --- a/src/recast/transform/numpy/constants.py +++ b/src/recast/transform/numpy/constants.py @@ -282,8 +282,10 @@ def _real(value: str, default_kind: bool) -> str: """Intrinsic -> how this target spells it in a constant expression.""" INQUIRY_SPELLING = { + "digits": "53", "epsilon": "np.finfo(np.float64).eps", "huge": "np.finfo(np.float64).max", + "radix": "2", "tiny": "np.finfo(np.float64).tiny", } """Type inquiries: the argument only says which type is being asked about.""" diff --git a/src/recast/transform/numpy/expressions.py b/src/recast/transform/numpy/expressions.py index 24c387e..d263b6c 100644 --- a/src/recast/transform/numpy/expressions.py +++ b/src/recast/transform/numpy/expressions.py @@ -28,6 +28,7 @@ from __future__ import annotations +import ast import re from dataclasses import dataclass, field from typing import Any @@ -59,11 +60,19 @@ DIM_KEYWORD = re.compile(r"dim\s*=\s*", re.I) -BOUND_TOKENS = re.compile(r"[A-Za-z_]\w*\s*%\s*[A-Za-z_]\w*|[A-Za-z_]\w*|\d+|[()+\-*/, ]") +BOUND_TOKENS = re.compile( + rf"{EXTENT.pattern}|[A-Za-z_]\w*\s*%\s*[A-Za-z_]\w*|[A-Za-z_]\w*|\d+|[()+\-*/, ]", + re.I, +) """What a declared bound is allowed to be made of. Bound texts are simple by -construction; anything richer refuses the statement that needed the bound.""" +construction; anything richer refuses the statement that needed the bound. + +``SIZE``/``UBOUND`` leads the alternation because an inquiry is one token +here, comma and all: ``2*size(c,2)`` is arithmetic *over* an extent, and a +tokenizer that took ``size`` for a plain name would stop at the comma it is +not allowed to contain.""" -__all__ = ["REFUSED", "Expressions", "Remote"] +__all__ = ["REFUSED", "Expressions", "Remote", "function_outputs"] REFUSED = (NoRule, Unanalyzable) """The two ways a rule declines: no rule for the construct, or the semantics @@ -169,6 +178,62 @@ class Remote: """What it is called there, which a use-rename may make different.""" +def function_outputs(record: dict[str, Any]) -> list[dict[str, Any]]: + """The OUT/INOUT dummies a function hands back beside its result. + + A Fortran function may change its arguments -- SLSQP's ``linmin`` drives + a reverse-communication line search through ``mode`` and eighteen + INOUT scalars -- and a translation that returned the result alone kept + the search state at zero on every call, bit-exact in ``x`` and wrong in + everything the next call would read. A function with a mandatory + OUT/INOUT dummy therefore returns ``(result, *outputs)`` the way a + subroutine returns its outputs (``Statements.returned_value``), and a + reference to it is only translatable as the whole of an assignment, + where the statement layer unpacks that tuple (``Statements._call``). + Every OUT/INOUT dummy is in the tuple, optional ones included, so the + unpacking is the subroutine's. Empty for a function whose only + OUT/INOUT dummies are optional: those are dropped from the call and it + stays a plain expression, as it always was. + """ + outputs = [a for a in record.get("args") or () if a.get("intent") in ("OUT", "INOUT")] + if record.get("kind") != "function" or not any(not a.get("optional") for a in outputs): + return [] + return outputs + + +class _IntegerDivision(ast.NodeTransformer): + """``a / b`` in a declared bound is Fortran integer division.""" + + def visit_BinOp(self, node: ast.BinOp) -> ast.AST: + self.generic_visit(node) + if isinstance(node.op, ast.Div): + return ast.Call( + func=ast.Name(id="_f_int_div", ctx=ast.Load()), + args=[node.left, node.right], + keywords=[], + ) + return node + + +def _integer_divisions(text: str) -> str: + """A rendered bound with every ``/`` made the integer division it is. + + A declared extent is an integer expression, so ``(n+1)*(n+2)/2`` -- the + packed triangle SLSQP hands ``slsqpb`` as ``l`` -- truncates in Fortran. + Rendered with Python's ``/`` it was a float, and the slice it sized the + workspace view with refused it ("slice indices must be integers"). + ``_f_int_div`` is what the statement layer already spells the operator + as, so a bound rounds the way the body does. + """ + if "/" not in text: + return text + try: + tree = ast.parse(text, mode="eval") + except SyntaxError: + return text + return ast.unparse(_IntegerDivision().visit(tree).body) + + @dataclass class Expressions: """Render Fortran expressions for one subprogram.""" @@ -403,7 +468,29 @@ def _power(self, left: str, right: str, left_node: Any, right_node: Any) -> str spelling = self.intrinsics.get("array", {}).get("**", "_f_vpow") return f"{spelling}({left}, {right})" scalar = self.intrinsics.get("scalar", {}).get("**") - return f"{scalar}({left}, {right})" if scalar else None + if scalar: + return f"{scalar}({left}, {right})" + if self.profile.int_pow_expand and self._integer_exponent(left_node, right_node): + # ``(xe(i)-x0)**(j-1)``: the exponent is an integer and the + # emitter cannot see which one, so ``expand_power`` above had + # nothing to expand and what was left was Python's ``**`` -- a + # ``pow`` call the reference binary never makes (``_f_powi``). + return f"_f_powi({left}, {right})" + return None + + 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. + """ + try: + return self.semantics.is_integer(right_node) and not self.semantics.is_integer( + left_node + ) + except Unanalyzable: + return False def _comparison(self, spelling: str, left: Any, right: Any, rl: str, rr: str) -> str: if rl in self.handles and ((RELATIONAL_OPS[spelling], rr) in ((">", "0"), (">=", "1"))): @@ -551,9 +638,6 @@ def extent(match: re.Match[str]) -> str: if EXTENT.fullmatch(text): return EXTENT.sub(extent, text) - substituted = EXTENT.sub(extent, text) - if substituted != text: - text = substituted rendered, position = [], 0 opens_intrinsic = False # the next "(" opens a max/min call calls: list[bool] = [] # per open parenthesis: a max/min call? @@ -562,7 +646,16 @@ def extent(match: re.Match[str]) -> str: raise NoRule(f"dim expr {text!r}") position = match.end() piece = match.group(0) - if "%" in piece: + inquiry = EXTENT.fullmatch(piece) + if inquiry is not None: + # ``size(x)-1``, ``2*size(c,2)``: an extent is a *term* of a + # bound, not only a whole one. Substituting it into the text + # before this loop spelled ``np.size(x) - 1``, which the loop + # then refused at the ``.`` it has no token for -- an array + # the source sizes off its argument, deferred over the + # spelling of the answer rather than over the question. + rendered.append(extent(inquiry)) + elif "%" in piece: # ``bounds%begp`` sizing a local: the component of a dummy, # which is an attribute of the same name on this side. root, component = (t.strip() for t in piece.split("%", 1)) @@ -597,7 +690,7 @@ def extent(match: re.Match[str]) -> str: rendered.append(piece) if position != len(text): raise NoRule(f"dim expr {text!r}") - return "".join(rendered) + return _integer_divisions("".join(rendered)) def extent_of(self, name: str) -> str: """How many elements an array has, as this target spells it.""" @@ -806,20 +899,34 @@ def actual_argument( # that is. Rendering the element alone -- what an unbounded # dummy used to get -- hands the callee one number to subscript. if element: - return self._association_tail(actual, formal_dims) + return self._association_tail(actual, formal_dims, substitutions) if rank is not None and 0 < rank < len(formal_dims): - raise NoRule( - f"seq-assoc: rank-{rank} actual for the rank-{len(formal_dims)} " - f"assumed-size dummy {formal['name']}" - ) + # ``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. + leading = [self.extent(d, substitutions) for d in formal_dims[:-1]] + return f"np.reshape({rendered}, ({', '.join(leading)}, -1), order='F')" + 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 + # order, from the first element. Rendering the array alone + # handed the callee two axes to subscript with one index. + leading = [self.extent(d, substitutions) for d in formal_dims[:-1]] + return f"_f_seq_tail({', '.join([rendered, '0', *leading])})" return rendered if not all(d.get("ub") for d in formal_dims): return rendered if rank is not None and 0 < rank < len(formal_dims): # Fortran sequence association: a lower-rank actual fills the - # dummy in column-major order. - shape = ", ".join(self.extent(d, substitutions) for d in formal_dims) - return f"np.reshape({rendered}, ({shape},), order='F')" + # dummy in column-major order, and the dummy takes only as much + # of it as its extents span -- ``nnls(w, n1, n1, m, ...)`` hands + # ``a(mda, n)`` the first ``n1*m`` cells of a longer workspace, + # and reshaping the whole of ``w`` raised on the size. + extents = [self.extent(d, substitutions) for d in formal_dims] + span = " * ".join(f"({axis})" for axis in extents) + flat = rendered if rank == 1 else f"np.ravel({rendered}, order='F')" + return f"np.reshape({flat}[:{span}], ({', '.join(extents)},), order='F')" if element: return self.sequence_association(actual, formal_dims, substitutions) return rendered @@ -829,24 +936,30 @@ def _assumed_size(formal_dims: list[dict[str, Any]]) -> bool: """``x(*)`` or ``x(n, *)``: the last axis has no extent of its own.""" return bool(formal_dims and formal_dims[-1].get("assumed_size")) - def _association_tail(self, actual: Any, formal_dims: list[dict[str, Any]]) -> str: + def _association_tail( + self, actual: Any, formal_dims: list[dict[str, Any]], substitutions: dict[str, str] + ) -> str: """An element actual for an assumed-size dummy: the actual's memory - from the element on, as a view the callee reads and writes in place. - - Only a rank-1 actual has that as a view. A higher-rank actual's tail - in column-major order is ``ravel(order='F')``, which is a copy unless - the array happens to be Fortran-contiguous, and a copy is somewhere - an OUT dummy's writes are lost; a rank-2 assumed-size dummy has no - extent to reshape to at all. Both are refused. + from the element on, as the array the callee reads and writes. + + A rank-1 actual to a rank-1 dummy is a plain slice, a view. Anything + else -- ``a(i, 1)`` to ``dx(*)``, ``c(i, 1)`` to ``u(iue, *)``, a + 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), + la, ...)`` and ``h12(..., c(i, 1), lc, ..., c(j, 1), ...)`` were + refused here, which deferred every block that recovers a matrix row. """ name = str(actual.children[0]).lower() declaration = self.semantics.declaration(name) or {} - if len(declaration.get("dims") or []) != 1 or len(formal_dims) != 1: - raise NoRule( - f"seq-assoc: element of {name} for an assumed-size dummy is only a view " - "when both are rank-1" - ) - return f"{self.names.symbol(name)}[{self._association_start(actual)}:]" + symbol = self.names.symbol(name) + start = self._association_start(actual) + if len(declaration.get("dims") or []) == 1 and len(formal_dims) == 1: + return f"{symbol}[{start}:]" + leading = [self.extent(d, substitutions) for d in formal_dims[:-1]] + return f"_f_seq_tail({', '.join([symbol, start, *leading])})" def substitutions(self, record: dict[str, Any], actuals: list[Any]) -> dict[str, str]: """Formal name -> the actual bound to it, rendered in the caller's scope. @@ -886,7 +999,16 @@ def sequence_association_target( if self._assumed_size(formal_dims): # The tail view the callee was handed; the copy-out onto it is # the same memory, so the writes it made in place stand. - return self._association_tail(actual, formal_dims), True + tail = self._association_tail(actual, formal_dims, substitutions) + if not tail.startswith("_f_seq_tail("): + return tail, True + # A higher-rank tail has no slice to assign through, so the + # runtime writes the callee's array back into the caller's + # column-major storage: ``{}`` is where the value goes, whole, + # so the runtime can tell the view it handed out from a copy. + name = str(actual.children[0]).lower() + start = self._association_start(actual) + return f"_f_seq_tail_out({self.names.symbol(name)}, {start}, {{}})", False whole = self._leading_axes_whole(actual, formal_dims) if whole is not None: # The view the callee was handed is where its result lands (#27); @@ -1096,13 +1218,15 @@ def _call(self, name: str, items: list[Any], arguments: list[str]) -> str | None if remote else pysafe(emit_name(record or {"name": name})) ) - if record is not None and not remote: + if record is not None: # Sequence association applies to a function reference as much as # to a CALL: ``enorm(m, a(1, j))`` hands the callee the whole of # column ``j``, and rendering the element alone hands it a scalar # to subscript. Only where every actual is positional -- a keyword # actual is not bound to a formal by position, and guessing which - # dummy it answers is how the reshape lands on the wrong one. + # dummy it answers is how the reshape lands on the wrong one. A + # sibling's function is bound the same way: ``ddot(n, w(i4), 1, + # w(iff), 1)`` into a translated BLAS handed ``ddot`` two scalars. positional = not any( isinstance(item, (f03.Actual_Arg_Spec, f03.Component_Spec)) for item in items ) @@ -1112,6 +1236,26 @@ def _call(self, name: str, items: list[Any], arguments: list[str]) -> str | None self.actual_argument(formal, item, substitutions) for formal, item in zip(record["args"], items, strict=False) ] + if record is not None and function_outputs(record): + # The callee hands its OUT/INOUT dummies back beside its result, + # and an expression has nowhere to put them. Only the statement + # layer, for ``x = f(...)`` as a whole, unpacks that tuple. + raise NoRule( + f"function {name} has OUT/INOUT dummy argument(s) " + f"{', '.join(a['name'] for a in function_outputs(record))}, which only a " + "whole-statement reference `x = f(...)` can carry back" + ) + if record is not None and not remote: + if record.get("host_writes"): + # A function reference is an expression: there is no place + # in it for the host variables the body changes to land, and + # a subroutine convention here would put a tuple where the + # caller expects a value. + raise NoRule( + f"internal function {name} writes host variable(s) " + f"{', '.join(record['host_writes'])}, which a function reference " + "cannot carry back" + ) arguments = [ *arguments, *(self.names.symbol(hv) for hv in record.get("host_vars") or ()), @@ -1196,6 +1340,11 @@ def _reduction(self, name: str, arguments: list[str]) -> str: if collapses_an_axis: # Fortran's DIM is 1-based and names a dimension; an axis is 0-based. return self.axis_reduction(REDUCTIONS[name], arguments[0], arguments[1]) + if name == "sum" and len(arguments) == 1: + # Whole-array SUM folds left to right; np.sum is pairwise and + # rounds an ULP off. The runtime shim accumulates in order, the + # way DOT_PRODUCT already does and for the same reason. + return f"_f_vsum({arguments[0]})" return f"{REDUCTIONS[name]}({', '.join(arguments)})" def axis_reduction(self, spelling: str, array: str, dimension: str) -> str: diff --git a/src/recast/transform/numpy/modules.py b/src/recast/transform/numpy/modules.py index c813edb..13d06bd 100644 --- a/src/recast/transform/numpy/modules.py +++ b/src/recast/transform/numpy/modules.py @@ -30,10 +30,12 @@ from __future__ import annotations import re +from collections.abc import Iterable from dataclasses import dataclass from pathlib import Path, PurePosixPath from typing import Any +from recast import references from recast.errors import ConfigError from recast.fortran._parse import f03, parse, walk from recast.fortran.interface import emit_name, subprogram_key @@ -167,11 +169,28 @@ def _submodule_exports(self) -> str: def _stub_imports(self, body: list[str] | None) -> list[str]: """The auto-stub imports the file needs: every one when told to keep them, otherwise only those whose alias the body binds to.""" - imports = list(self.subprograms.stub_imports) + return self._bound_imports(self.subprograms.stub_imports, body) + + def _companion_imports(self, body: list[str] | None) -> list[str]: + """The companions' imports the file needs. + + The same rule as the auto-stubs, and for the same reason (#18): a + ``use`` that brought nothing but a kind parameter binds no alias, and + the file that imports its sibling's translation anyway raises + ``ModuleNotFoundError`` before running a line -- whether that sibling + rides along in the candidate or not. Naming only what it calls is what + lets the candidate be self-contained without carrying the tree. + """ + return self._bound_imports(self.companion_imports, body) + + def _bound_imports(self, imports: Iterable[str], body: list[str] | None) -> list[str]: + """Every one when told to keep them, otherwise only those whose alias + the body binds to.""" + lines = list(imports) if self.keep_unbound_stub_imports or body is None: - return imports + return lines text = "\n".join(body) - return [line for line in imports if f"{line.rsplit(' as ', 1)[1]}." in text] + return [line for line in lines if f"{line.rsplit(' as ', 1)[1]}." in text] def header(self, body: list[str] | None = None) -> str: record = self.subprograms.record @@ -202,7 +221,7 @@ def header(self, body: list[str] | None = None) -> str: shims = self.externals_module or (record["module"] + "_externals") pieces.append(f"import {shims} as _ext") extra = sorted( - set(self.companion_imports) + set(self._companion_imports(body)) | set(self._stub_imports(body)) | { imported @@ -232,6 +251,10 @@ def body(self, nodes: dict[str, Any]) -> tuple[list[str], list[dict[str, Any]]]: for state in self.subprograms.record["module_state"]: lines.extend(self._state(state, report)) lines.append("") + # Ahead of the subprograms, the way the source declares a procedure + # ahead of the code that calls it -- and so the last subprogram's span + # still ends at the end of the file (``_rebase``). + lines.extend(references.python_for(self._reference_externals())) for record in self.subprograms.record["subprograms"]: node = nodes.get(subprogram_key(record)) if node is None: @@ -249,6 +272,42 @@ def body(self, nodes: dict[str, Any]) -> tuple[list[str], list[dict[str, Any]]]: report.extend(entries) return lines, report + # -- procedures declared here and defined nowhere -------------------------- + + def _reference_externals(self) -> list[str]: + """Names this module declares, nothing defines, and recast can supply. + + ``use lapack, only: dgesv, dgbsv`` names a module whose whole content + is interface blocks; the bodies are in a compiled library. Those + declarations bind like module procedures (``semantics.for_subprogram`` + merges them in), so a caller is translated to ``_lapack.dgbsv(...)`` -- + and this file, which *is* the ``lapack`` translation, had nothing of + that name in it. ``recast.references`` holds an implementation for a + few of them, and the reference build compiles the Fortran twin of the + same one, so the call means the same thing on both sides. + + Three kinds of name are left alone, each because something else + already defines it: one this module or a companion has a body for; one + a submodule of this module defines, which ``_submodule_exports`` + re-exports (#29); and one the operator gave an audited shim in the + externals module. Everything else recast has no implementation for + stays as it was -- declared here, defined nowhere, and disclaiming its + callers in the oracle. + """ + record = self.subprograms.record + defined = {s["name"] for s in record["subprograms"]} + for names in (record.get("submodules") or {}).values(): + defined |= set(names) + for companion in self.subprograms.companions: + defined |= {s["name"] for s in companion.get("subprograms") or ()} + return references.supported( + declared["name"] + for declared in (record.get("interfaces") or {}).values() + if declared.get("kind") in ("subroutine", "function") + and declared["name"] not in defined + and declared["name"] not in self.subprograms.externals + ) + # -- derived-type factories ----------------------------------------------- def _all_types(self) -> dict[str, dict[str, Any]]: @@ -486,6 +545,11 @@ def _signatures(self) -> dict[str, dict[str, Any]]: entry["dims"] = [ {"lb": d.get("lb", "1"), "ub": d.get("ub")} for d in argument["dims"] ] + if argument.get("path"): + # A character dummy the body opens as a file, and what its + # OPEN asks of it. The harness that supplies arguments has + # to know a scratch path from a message. + entry["path"] = argument["path"] if argument.get("buffer") and self.subprograms.buffer_out_arrays: # The caller's storage: a harness has to pass one in. entry["buffer"] = True @@ -502,6 +566,23 @@ def _signatures(self) -> dict[str, dict[str, Any]]: "args": arguments, "result": subprogram.get("result"), "result_dtype": subprogram.get("result_dtype"), + # What the body's own entry checks say its dummies' shapes + # must be. An assumed-shape dummy declares neither extent, so + # for a harness that has to supply one this is the only + # statement of it there is. + **( + {"shape_guards": subprogram["shape_guards"]} + if subprogram.get("shape_guards") + else {} + ), + # ... and what they say about their values. An integer + # dummy the body will only take two values of is a mode + # selector nothing else declares as one. + **( + {"value_guards": subprogram["value_guards"]} + if subprogram.get("value_guards") + else {} + ), } return table diff --git a/src/recast/transform/numpy/names.py b/src/recast/transform/numpy/names.py index d15cba4..d719033 100644 --- a/src/recast/transform/numpy/names.py +++ b/src/recast/transform/numpy/names.py @@ -83,6 +83,11 @@ def symbol(self, name: str) -> str: return pysafe(lowered) if any(loc["name"] == lowered for loc in subprogram.get("locals") or ()): return pysafe(lowered) + # A host variable arrives as a trailing parameter under its own name + # (``signature``), and it shadows the module's: cpoly's array ``pi``, + # read inside ``noshft``, is that parameter and not the constant PI. + if lowered in (subprogram.get("host_vars") or ()): + return pysafe(lowered) if lowered in self.module_parameters: return self.module_parameters[lowered] # Host module declarations shadow USE-imported names. diff --git a/src/recast/transform/numpy/runtime.py b/src/recast/transform/numpy/runtime.py index a0a2f44..8b42b84 100644 --- a/src/recast/transform/numpy/runtime.py +++ b/src/recast/transform/numpy/runtime.py @@ -66,13 +66,13 @@ def _f_vexp(x: Any) -> Any: def _f_vlog(x: Any) -> Any: if _LIBM_STRICT: - return np.array([math.log(v) for v in np.ravel(x)]).reshape(np.shape(x)) + return np.array([_f_log(v) for v in np.ravel(x)]).reshape(np.shape(x)) return np.log(x) def _f_vlog10(x: Any) -> Any: if _LIBM_STRICT: - return np.array([math.log10(v) for v in np.ravel(x)]).reshape(np.shape(x)) + return np.array([_f_log10(v) for v in np.ravel(x)]).reshape(np.shape(x)) return np.log10(x) @@ -85,6 +85,32 @@ def _f_vpow(a: Any, b: Any) -> Any: return a**b +def _f_powi(x: Any, n: Any) -> Any: + """``x ** n`` for an integer exponent the compiler could not read off. + + ``(xe(i)-x0)**(j-1)`` inside a loop is one: there is no literal to + expand, so what the emitter had left was Python's ``**``, which is a + libm ``pow`` call. gfortran does not call ``pow`` for an integer + exponent at any point -- libgcc's ``__powidf2`` squares and multiplies, + LSB first, exactly as ``expand_power`` spells out when the exponent *is* + a literal -- and the two are one to two ULP apart, which is enough to + fail a bit-exact gate and not enough to notice by looking. + """ + count = int(n) + remaining = -count if count < 0 else count + result: Any = None + square = x + while remaining: + if remaining & 1: + result = square if result is None else result * square + remaining >>= 1 + if remaining: + square = square * square + if result is None: + return x**0 + return 1.0 / result if count < 0 else result + + 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 @@ -95,6 +121,16 @@ def _f_cfold(fn: Any, *args: Any) -> Any: return float(getattr(mp, fn)(*[mp.mpf(float(a)) for a in args])) +def _f_vachar(x: Any) -> Any: + """Fortran ACHAR over an array: one character per element. + + ``chr`` elementwise rather than a NumPy ``str_`` view, because the + result is an item list a WRITE hands to the formatter one value at a + time, and a fixed-width NumPy string array would pad every one of them. + """ + 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) @@ -171,6 +207,60 @@ def _f_copy_out(dst: Any, src: Any) -> None: dst.ravel()[:n] = src.ravel()[:n] +def _f_seq_tail(arr: Any, start: Any, *leading: Any) -> Any: + """Sequence association of an array element with an assumed-size dummy. + + 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.""" + 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") + + +def _f_seq_tail_out(dst: Any, start: Any, src: Any) -> None: + """Copy a callee's returned assumed-size array back onto the storage + ``_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.""" + if dst is None or not isinstance(src, np.ndarray): + return + if np.may_share_memory(src, dst): + return + values = np.ravel(src, order="F") + flat = np.ravel(dst, order="F") + at = int(start) + n = min(values.size, max(flat.size - at, 0)) + if n <= 0: + return + flat[at : at + n] = values[:n] + if not np.may_share_memory(flat, dst): + dst[...] = np.reshape(flat, np.shape(dst), order="F") + + def _f_rstep(lo: Any, hi: Any, st: Any) -> Any: """Fortran lo:hi:st (st<0, inclusive, 1-based) -> python slice; the exclusive stop edge underflows at hi==1, which needs None.""" @@ -267,31 +357,96 @@ def _f_sqrt(x: Any) -> Any: return math.sqrt(x) if x >= 0.0 else float("nan") +def _f_log(x: Any) -> Any: + """Fortran LOG outside its domain is an IEEE value, not an exception. + + ``math.log`` raises ValueError at zero and below, where the compiled + reference carries on with ``-Infinity`` and ``NaN`` -- and where the body + that reached it usually clamps the result a line later, as + ``iixexp`` does with an index off an exponential mesh. Raising there + turns a number both sides agree on into "the candidate raised", which + the differential gate reports as no comparison at all. Positive + arguments go through ``math.log`` unchanged, which is the compiled + reference's own libm call. + """ + if x > 0.0: + return math.log(x) + return float("-inf") if x == 0.0 else float("nan") + + +def _f_log10(x: Any) -> Any: + """Fortran LOG10 outside its domain, for the reason ``_f_log`` gives.""" + if x > 0.0: + return math.log10(x) + return float("-inf") if x == 0.0 else float("nan") + + +_INT32_LIMIT = 2147483648.0 +"""One past the largest default INTEGER, as a float: the conversion's edge.""" + + +def _f_int(x: Any, kind: Any = None) -> Any: + """Fortran INT: truncate toward zero, into an integer of the given kind. + + Python's ``int`` raises on a NaN and grows without bound on a value no + INTEGER can hold; the compiled reference does neither. gfortran emits the + hardware conversion, which answers every value it cannot represent -- + NaN and overflow alike -- with the most negative integer of the kind, and + a body that has just taken ``LOG`` of something non-positive is exactly + where that happens. ``iixexp`` then clamps the index to ``1``, on both + sides, which is the number the comparison is about. + """ + dtype = np.int64 if int(kind or 4) == 8 else np.int32 + limit = _INT32_LIMIT * (2**32 if dtype is np.int64 else 1) + if isinstance(x, (int, np.integer)): + return dtype(x) + value = float(x) + if not -limit <= value < limit: # NaN compares false and lands here too + return dtype(-limit) + return dtype(int(value)) + + def _f_min(*xs: Any) -> Any: - """gfortran MIN (SSE minsd order, MEASURED): per fold step the FIRST - operand's NaN is absorbed, the second's propagates: - min(NaN,0)=0 but min(0,NaN)=NaN. Python's builtin min returns the - first arg on NaN — a one-sided-NaN behavior trap.""" + """gfortran MIN, as the f2py reference actually computes it (measured + against the built module, not a standalone toy): a NaN operand is + absorbed and the other returned wherever it falls -- ``min(NaN, x)`` and + ``min(x, NaN)`` are both ``x`` -- and the result is NaN only when every + operand is. This is IEEE ``fmin`` order. gfortran at the golden ``-O1 + -fno-fast-math`` flags emits the comparison with the *computed* operand in + the position that makes the constant win, so a body reaching ``min(1.0_wp, + x)`` with ``x`` gone NaN keeps the 1.0 -- a comparison model + (``(a Any: r = xs[0] for b in xs[1:]: - r = b if (r != r) else (r if r > b else b) + if r != r: + r = b + elif b == b and b > r: + r = b return r def _f_vmin(a: Any, b: Any) -> Any: - """elementwise gfortran MIN semantics (see _f_min).""" - return np.where(np.isnan(a), b, np.where(a < b, a, b)) + """elementwise gfortran MIN semantics (see _f_min): a NaN in either + operand is absorbed, so ``fmin`` rather than a comparison that would let + an operand's NaN through.""" + return np.fmin(a, b) def _f_vmax(a: Any, b: Any) -> Any: - return np.where(np.isnan(a), b, np.where(a > b, a, b)) + return np.fmax(a, b) def _f_nint(x: Any) -> Any: @@ -323,37 +478,70 @@ def _f_int_div(a: Any, b: Any) -> Any: return int(a / b) -def _f_list_write(*items: Any) -> Any: - """gfortran list-directed internal WRITE shim. +def _f_io_values(items: Any) -> list[Any]: + """An output item list, one value per element. + + A whole array is as many values as it has elements -- Fortran writes them + all, and repeats the format over them -- so an item that is an array is + flattened here rather than handed to a formatter that would ask an array + for its single float. + """ + values: list[Any] = [] + for item in items: + if isinstance(item, np.ndarray) and item.ndim: + values.extend(np.ravel(item).tolist()) + else: + values.append(item) + return values - Byte-exact against reference probes: a record starts with one blank, - strings print verbatim, ``int32`` becomes I12 plus a blank separator, - ``real(8)`` becomes G25.17E3 plus a blank. - Percent formatting throughout, deliberately. The point of this function - is to reproduce another language's output byte for byte, and ``%`` is - the spelling whose width, precision and sign rules match the Fortran - edit descriptors it is emulating. Restating them in ``format`` would be - a re-derivation of something already validated against real output. +def _f_list_write(*items: Any) -> Any: + """gfortran list-directed WRITE: the record it produces, item by item. + + Measured against gfortran, because the point of this function is to + reproduce another language's output byte for byte. An ``integer`` is a + 12-column right-justified field; a ``real(8)`` is 26 columns (a G25.17E3 + field and the blank that separates it from the next); a ``character`` is a + blank and then its own characters; a ``logical`` is a blank and ``T`` or + ``F``. Nothing is prepended to the record: the leading blank a + list-directed record famously starts with is the first field's own + padding, and adding one as well put every record a column out. + + Percent formatting throughout, deliberately: ``%`` is the spelling whose + width, precision and sign rules match the Fortran edit descriptors this + is emulating, and restating them in ``format`` would be a re-derivation + of something already validated against real output. """ - out = " " - for it in items: + out = "" + for it in _f_io_values(items): if isinstance(it, str): - out += it + out += " " + it + elif isinstance(it, (bool, np.bool_)): + out += " T" if it else " F" elif isinstance(it, (int, np.integer)): - out += "%12d " % int(it) # noqa: UP031 + out += "%12d" % int(it) # noqa: UP031 else: - v = float(it) - av = abs(v) - if v == 0.0 or (0.1 <= av < 1e17): - int_digits = 0 if av < 1.0 else len(str(int(av))) - out += "%21.*f" % (17 - int_digits, v) + " " * 6 # noqa: UP031 - else: - mant, ex = ("%.16E" % v).split("E") # noqa: UP031 - out += ("%sE%+04d" % (mant, int(ex))).rjust(26) + " " # noqa: UP031 + out += _f_list_real(float(it)) return out +def _f_list_real(v: float) -> str: + """One ``real(8)`` of a list-directed record. + + The F form is right-justified in 21 columns with five blanks after it; + the E form is right-justified in 26. How many decimals the F form carries + is decided by the digits before the point -- seventeen significant + figures in all -- and zero has one such digit, which is why ``0.0`` comes + out with sixteen decimals where ``0.5`` has seventeen. + """ + av = abs(v) + if v == 0.0 or (0.1 <= av < 1e17): + int_digits = 1 if v == 0.0 else (0 if av < 1.0 else len(str(int(av)))) + return ("%.*f" % (17 - int_digits, v)).rjust(21) + " " * 5 # noqa: UP031 + mant, ex = ("%.16E" % v).split("E") # noqa: UP031 + return ("%sE%+04d" % (mant, int(ex))).rjust(26) # noqa: UP031 + + _FMT_TOKEN = _re.compile( r"\s*(?:(?P\d+)?\s*(?PI\d+(?:\.\d+)?|F\d+\.\d+" r"|E[SN]?\d+\.\d+(?:E\d+)?|G\d+\.\d+|A(?:\d+)?|L\d+|\d*X|/" @@ -362,19 +550,35 @@ def _f_list_write(*items: Any) -> Any: ) -def _f_fmt_write(fmt: str, *vals: Any) -> str: - """Formatted internal WRITE (#16), for the edit descriptors the corpus - uses: ``Iw[.m]``, ``Fw.d``, ``Ew.d`` / ``ESw.d``, ``Gw.d``, ``A[w]``, - ``Lw``, ``nX``, ``/``, literals, repeat counts. Fortran field semantics: - right-justified, asterisks on overflow, ``Iw.m`` zero-filled to ``m`` - digits, ``E`` as ``0.dddE+ee``.""" +def _f_fmt_records(fmt: str, values: list[Any]) -> list[str]: + """The records one formatted transfer writes, for the edit descriptors + the corpus uses: ``Iw[.m]``, ``Fw.d``, ``Ew.d`` / ``ESw.d``, ``Gw.d``, + ``A[w]``, ``Lw``, ``nX``, ``/``, literals, repeat counts. Fortran field + semantics: right-justified, asterisks on overflow, ``Iw.m`` zero-filled + to ``m`` digits, ``E`` as ``0.dddE+ee``. + + A *list* because a format shorter than its item list does not truncate: + Fortran reverts to the start of the format and, in doing so, ends the + record and begins another. ``write(u, '(3a1)') achar(pixel)`` on a + four-component pixel writes two records, and a translation that wrote one + would be a file the source never produced. ``/`` ends a record the same + way. + """ body = fmt.strip() if body.startswith("(") and body.endswith(")"): body = body[1:-1] + records: list[str] = [] out: list[str] = [] - values = list(vals) pos = 0 - while pos < len(body): + while True: + if pos >= len(body): + if not values: + break + if not body: + raise ValueError(f"_f_fmt_write: format {fmt!r} has no edit descriptor") + records.append("".join(out)) # format reversion ends the record + out, pos = [], 0 + continue m = _FMT_TOKEN.match(body, pos) if not m or m.end() == pos: raise ValueError(f"_f_fmt_write: cannot parse {fmt!r}") @@ -388,16 +592,34 @@ def _f_fmt_write(fmt: str, *vals: Any) -> str: elif u.endswith("X"): out.append(" " * (int(u[:-1]) if u[:-1] else 1)) elif u == "/": - out.append("\n") + records.append("".join(out)) + out = [] else: if not values: - return "".join(out) + # A data edit descriptor with no value left: the transfer + # ends here, and whatever the format has after it is not + # written. + records.append("".join(out)) + return records out.append(_fmt_one(u, values.pop(0))) - return "".join(out) + records.append("".join(out)) + return records + + +def _f_fmt_write(fmt: str, *vals: Any) -> str: + """Formatted internal WRITE (#16): every record the format produces, the + record terminators between them spelled ``\n`` the way an internal write + to a character array would hold them.""" + return "\n".join(_f_fmt_records(fmt, _f_io_values(vals))) def _fmt_one(u: str, v: Any) -> str: def fit(s: str, w: int) -> str: + # ``w = 0`` asks for the shortest field the value fits in -- what + # ``(i0)`` and ``(f0.6)`` are written for; the value never overflows + # a width it chooses itself. + if w == 0: + return s return s.rjust(w) if len(s) <= w else "*" * w if u[0] == "I": @@ -586,6 +808,14 @@ def _f_precision(x: Any) -> Any: return 15 +def _f_radix(x: Any) -> Any: + """Fortran RADIX: the base of the model number system, 2 for every + integer and IEEE real kind numpy has. A Python int, as RADIX is a + default integer: ``base**l`` then stays exact where an int32 would + wrap, and it widens to float64 the moment it meets one.""" + return 2 + + def _f_transfer(source: Any, mold: Any) -> Any: """Fortran TRANSFER: reinterpret bit pattern.""" src = np.array(source) @@ -749,6 +979,621 @@ def _f_ieee_value(x: Any, cls: Any) -> Any: _openacc = _FIntrinsicModule(acc_get_num_devices=lambda *_a: np.int32(0)) +# -- external files ---------------------------------------------------------- +# +# The I/O statements that write a variable -- READ, INQUIRE, OPEN's NEWUNIT= +# and IOSTAT= -- are translated rather than stubbed, because a stub drops +# those writes silently and leaves the variable at whatever it held. So is a +# WRITE to a unit the translation itself connected to a file, because for a +# subprogram whose only product is that file the stub leaves nothing at all. +# That needs a unit table, and the table needs a file position Fortran would +# recognise: between records after every advancing statement, inside one +# after a non-advancing transfer. +# +# Formatted sequential and stream access. Direct access, unformatted +# sequential records and namelists are refused by the emitter rather than +# approximated here. + +_F_UNITS: dict[int, Any] = {} +"""Connected unit number -> its connection. Module state, as Fortran's is.""" + +_F_PRECONNECTED = (0, 5, 6) +"""stderr, stdin, stdout: connected before the program starts, so INQUIRE +reports them OPENED without anything having opened them.""" + + +class _FConnection: + """One connected external file, positioned the way Fortran positions one. + + ``record`` is the record the file is positioned *inside* -- what a + non-advancing READ leaves behind. ``None`` means positioned between + records, which is where every advancing statement leaves it. ``starts`` + is where each record read so far began, which is what BACKSPACE needs. + """ + + def __init__(self, unit: int, path: Any, handle: Any, form: str, access: str) -> None: + self.unit = int(unit) + self.path = path + self.handle = handle + self.form = form + self.access = access + self.record: Any = None + self.column = 0 + self.starts: list[int] = [] + self.partial = False + """Whether a non-advancing WRITE left the file inside a record. + + Fortran has no incomplete record: CLOSE (and the end of the program) + terminates the one a ``advance='no'`` transfer left open, so a file + whose last write was non-advancing still ends with a record + terminator. ``saveppm``'s last pixel is written that way, and a + translation that dropped the terminator would be one byte short of + the file the source writes. + """ + + +def _f_default_form(access: str) -> str: + """The FORM a connection has when OPEN did not say: what the standard + says, which is unformatted for direct and stream access and formatted + otherwise. + + Stream is the one worth spelling out. ``open(newunit=u, file=f, + access='stream')`` with no FORM= is how a Fortran program reads a file + byte by byte -- gfortran reports UNFORMATTED for it -- and reading those + bytes as text records would take a PPM's pixels for a record. + """ + return "unformatted" if access in ("direct", "stream") else "formatted" + + +def _f_newunit() -> int: + """A unit number NEWUNIT= can hand out: negative, the way gfortran's is, + so a routine scanning 10..999 with INQUIRE never collides with one.""" + n = -10 + while n in _F_UNITS: + n -= 1 + return n + + +def _f_open( + unit: Any = None, + file: Any = None, + status: str = "unknown", + access: str = "sequential", + form: Any = None, + position: str = "asis", + action: str = "readwrite", + recl: Any = None, + strict: bool = True, +) -> tuple[Any, Any]: + """Fortran OPEN. ``unit=None`` is NEWUNIT=. Returns ``(iostat, unit)``. + + ``strict`` is False only where the source wrote IOSTAT=: a statement + without it aborts the program in Fortran, so this raises there. + """ + number = _f_newunit() if unit is None else int(unit) + st, act, acc = str(status).lower(), str(action).lower(), str(access).lower() + shape = str(form).lower() if form is not None else _f_default_form(acc) + path = None if file is None else str(file) + if path is None: + return _f_io_error(5000, "OPEN without FILE= (STATUS='SCRATCH' is refused)", strict, number) + exists = os.path.exists(path) + if st == "old" and not exists: + missing = f"OPEN(STATUS='OLD') on {path!r}, which does not exist" + return _f_io_error(2, missing, strict, number) + if st == "new" and exists: + return _f_io_error(17, f"OPEN(STATUS='NEW') on {path!r}, which exists", strict, number) + if act == "read": + mode = "r" + elif st in ("new", "replace") or not exists: + mode = "w+" + else: + mode = "r+" + try: + # A formatted connection is text, but Fortran's characters are bytes: + # a program that reads a PPM header with FORM='FORMATTED' and its + # pixels through a second connection would hit a decode error on the + # binary tail of the very first buffered read. latin-1 is the codec + # that maps every byte to one character, and ``newline=""`` leaves the + # record terminators as they lie, so POS= counts what the file holds. + handle = ( + open(path, mode + "b") + if shape == "unformatted" + else open(path, mode, encoding="latin-1", newline="") + ) + except OSError as error: + return _f_io_error(error.errno or 5000, str(error), strict, number) + if str(position).lower() == "append": + handle.seek(0, 2) + _f_close(number, strict=False) + _F_UNITS[number] = _FConnection(number, path, handle, shape, acc) + return np.int32(0), np.int32(number) + + +def _f_io_error(code: int, message: str, strict: bool, unit: Any = -1) -> tuple[Any, Any]: + if strict: + raise OSError(f"Fortran I/O error {code}: {message}") + return np.int32(code), np.int32(unit) + + +def _f_close(unit: Any, status: Any = None, strict: bool = True) -> Any: + """Fortran CLOSE. Closing a unit nothing connected is not an error.""" + conn = _F_UNITS.pop(int(unit), None) + if conn is None: + return np.int32(0) + if conn.partial: + conn.handle.write("\n") + conn.handle.close() + if str(status).lower() == "delete" and conn.path is not None: + try: + os.remove(conn.path) + except OSError as error: + return _f_io_error(error.errno or 5000, str(error), strict)[0] + return np.int32(0) + + +def _f_connection(unit: Any, strict: bool) -> Any: + conn = _F_UNITS.get(int(unit)) + if conn is None and strict: + raise OSError(f"Fortran I/O error: unit {int(unit)} is not connected") + return conn + + +def _f_rewind(unit: Any, strict: bool = True) -> Any: + conn = _f_connection(unit, strict) + if conn is None: + return np.int32(5001) + conn.handle.seek(0) + conn.record, conn.column, conn.starts = None, 0, [] + return np.int32(0) + + +def _f_backspace(unit: Any, strict: bool = True) -> Any: + """Fortran BACKSPACE: position before the record just read.""" + conn = _f_connection(unit, strict) + if conn is None: + return np.int32(5001) + if conn.starts: + conn.handle.seek(conn.starts.pop()) + conn.record, conn.column = None, 0 + return np.int32(0) + + +def _f_endfile(unit: Any, strict: bool = True) -> Any: + conn = _f_connection(unit, strict) + if conn is None: + return np.int32(5001) + conn.handle.truncate() + conn.record, conn.column = None, 0 + return np.int32(0) + + +def _f_flush(unit: Any, strict: bool = True) -> Any: + conn = _f_connection(unit, strict) + if conn is None: + return np.int32(5001) + conn.handle.flush() + return np.int32(0) + + +def _f_inquire(unit: Any, file: Any, what: str) -> Any: + """One INQUIRE output specifier's value. + + One call per specifier, because every specifier is a write and the + emitter renders each as its own assignment. + """ + key = str(what).lower() + conn = None + path = None if file is None else str(file) + if path is not None: + conn = next((c for c in _F_UNITS.values() if c.path == path), None) + elif unit is not None: + conn = _F_UNITS.get(int(unit)) + connected = conn is not None or ( + path is None and unit is not None and int(unit) in _F_PRECONNECTED + ) + if key == "opened": + return bool(connected) + if key == "exist": + return bool(os.path.exists(path)) if path is not None else bool(connected) + if key == "named": + return bool(conn is not None and conn.path is not None) + if key == "name": + return conn.path if conn is not None and conn.path is not None else "" + if key == "number": + return np.int32(conn.unit if conn is not None else -1) + if key == "size": + target = path if path is not None else (conn.path if conn is not None else None) + return np.int32(os.path.getsize(target) if target and os.path.exists(target) else -1) + if key == "pos": + return np.int32(conn.handle.tell() + 1 if conn is not None else -1) + if key == "iostat": + return np.int32(0) + if key == "iomsg": + return "" + if key in ( + "form", + "access", + "action", + "position", + "sequential", + "direct", + "formatted", + "unformatted", + "recl", + "nextrec", + ): + return _f_inquire_connection(conn, key) + raise ValueError(f"_f_inquire: unsupported specifier {what!r}") + + +def _f_inquire_connection(conn: Any, key: str) -> Any: + """The specifiers that describe *how* a unit is connected.""" + if key == "recl": + return np.int32(-1) + if key == "nextrec": + return np.int32(0) + if conn is None: + return np.int32(-1) if key in ("recl", "nextrec") else "UNDEFINED" + if key == "form": + return conn.form.upper() + if key == "access": + return conn.access.upper() + if key == "action": + return "READWRITE" + if key == "position": + return "ASIS" + if key == "sequential": + return "YES" if conn.access == "sequential" else "NO" + if key == "direct": + return "YES" if conn.access == "direct" else "NO" + if key == "formatted": + return "YES" if conn.form == "formatted" else "NO" + return "YES" if conn.form == "unformatted" else "NO" + + +def _f_print(fmt: Any, *items: Any) -> None: + """PRINT: the record it writes to standard output, formatted the way + gfortran formats one. Kept rather than stubbed because the item list is + a read, and a stub told the read/write gate nothing was read.""" + print(_f_list_write(*items) if fmt is None else _f_fmt_write(str(fmt), *items)) + + +def _f_write(unit: Any, fmt: Any = None, items: Any = (), advance: Any = "yes") -> Any: + """Fortran WRITE to an external unit: the records it puts in the file. + + Translated rather than stubbed for a unit the program itself connected, + because for a routine whose whole purpose is the file it produces -- + ``saveppm`` writes a PPM and returns nothing else -- a stub is not a + lossy translation but an empty one, and there is nothing left for a + differential to compare. + + A unit no OPEN in this translation connected is the log destination it + always was: the preconnected standard output, or a diagnostic the source + sends to a unit the caller connected. Those records are written where + ``PRINT``'s go, so the item list is still read, and no file is invented + for a connection this translation does not hold. + + ``advance='no'`` leaves the file inside the record, which is what the + next WRITE then continues; every other transfer ends it. + """ + values = _f_io_values(items) + conn = _F_UNITS.get(int(unit)) + if conn is None: + print(_f_list_write(*values) if fmt is None else _f_fmt_write(str(fmt), *values)) + return np.int32(0) + if conn.form == "unformatted": + conn.handle.write(_f_unformatted_bytes(values)) + return np.int32(0) + records = [_f_list_write(*values)] if fmt is None else _f_fmt_records(str(fmt), values) + text = "\n".join(records) + nonadvancing = str(advance).strip().lower() == "no" + if not nonadvancing: + text += "\n" + conn.handle.write(text) + conn.partial = nonadvancing + return np.int32(0) + + +def _f_unformatted_bytes(values: list[Any]) -> bytes: + """One unformatted transfer's item list, as the bytes gfortran writes for + a stream connection: a character is its own byte, a number its raw + little-endian image. A LOGICAL is refused for the reason + ``_f_read_stream`` refuses to read one -- four bytes to gfortran, one to + NumPy.""" + out = bytearray() + for value in values: + if isinstance(value, str): + out += value.encode("latin-1") + elif isinstance(value, (bool, np.bool_)): + raise OSError("Fortran I/O error: unformatted stream WRITE of bool") + else: + out += np.asarray(value).tobytes() + return bytes(out) + + +_F_READ_DTYPES = { + "float64": np.float64, + "float32": np.float32, + "int32": np.int32, + "int64": np.int64, + "bool": np.bool_, +} +"""Item dtype -> what a parsed field becomes. ``str`` is the field itself.""" + +_F_REPEAT = _re.compile(r"(\d+)\*(.*)") +"""``3*1.0`` in list-directed input: three values, not one.""" + + +def _f_seek(conn: Any, pos: Any) -> None: + """POS=: put the connection at the ``pos``th byte of the file, counting + from one, the way Fortran counts a stream position. Whatever record the + connection was positioned inside is left behind with it.""" + conn.handle.seek(max(int(pos) - 1, 0)) + conn.record, conn.column = None, 0 + + +def _f_read( + unit: Any, + fmt: Any = None, + items: Any = (), + advance: str = "yes", + pos: Any = None, + strict: bool = True, +) -> tuple[Any, ...]: + """Fortran READ from a connected unit. + + ``items`` is one ``(dtype, count, width)`` per input item: ``count`` is + None for a scalar and the element count for an array item, ``width`` the + declared character length. Returns ``(iostat, value, ...)`` in item + order, so the emitter unpacks the statement's item list straight out of + it -- which is the point of translating READ at all. + """ + blanks = tuple(_f_read_blank(spec) for spec in items) + conn = _F_UNITS.get(int(unit)) + if conn is None: + if strict: + raise OSError(f"Fortran I/O error: READ on unit {int(unit)}, which is not connected") + return (np.int32(5002), *blanks) + if pos is not None: + _f_seek(conn, pos) + if conn.form != "formatted": + if conn.access != "stream": + # Refused rather than approximated: an unformatted *record's* + # layout -- the length markers around it -- is the compiler's, and + # guessing at it would put wrong numbers in the right variables. + # An unformatted stream has no records and nothing to guess: the + # file is the values, laid end to end, which is why it is read + # below rather than refused with them. + raise OSError(f"Fortran I/O error: READ from unformatted unit {int(unit)}") + values, ios = _f_read_stream(conn, items) + if ios == 0: + return (np.int32(0), *values) + if strict: + raise EOFError(f"Fortran I/O error {ios} reading unit {int(unit)}") + return (np.int32(ios), *blanks) + spelled = "*" if fmt is None else str(fmt).strip() + if spelled == "*": + values, ios = _f_read_list(conn, items) + else: + values, ios = _f_read_formatted(conn, spelled, items, str(advance).lower() == "no") + if ios != 0: + if strict: + raise EOFError(f"Fortran I/O error {ios} reading unit {int(unit)}") + return (np.int32(ios), *blanks) + return (np.int32(0), *values) + + +def _f_read_blank(spec: Any) -> Any: + """What an item keeps when the READ that would have written it failed.""" + dtype, count, _width = spec + if count is not None: + if dtype == "str": + return np.array([""] * int(count), dtype=object) + return np.zeros(int(count), dtype=_F_READ_DTYPES.get(dtype, np.float64)) + if dtype == "str": + return "" + return _F_READ_DTYPES.get(dtype, np.float64)(0) + + +def _f_read_stream(conn: Any, items: Any) -> tuple[Any, int]: + """An unformatted stream READ: the items, taken from the file as bytes. + + No records and no length markers -- ``access='stream'`` with no FORM= is + the connection a program reads a PPM's pixels through, one ``character`` + per byte -- so each item takes exactly the storage its type occupies. + A short read is end of file, and leaves the items alone. + """ + values: list[Any] = [] + for dtype, count, width in items: + size = int(count) if count is not None else 1 + if dtype == "str": + each = int(width) if width else 1 + raw = conn.handle.read(size * each) + if len(raw) < size * each: + return (), -1 # IOSTAT_END + text = raw.decode("latin-1") + taken: Any = [text[at * each : (at + 1) * each] for at in range(size)] + if count is None: + values.append(taken[0]) + continue + values.append(np.array(taken, dtype=object)) + continue + if dtype not in _F_READ_DTYPES or dtype == "bool": + # A LOGICAL is four bytes to gfortran and one to NumPy; reading it + # here would put the wrong bytes in the right variable. + raise OSError(f"Fortran I/O error: unformatted stream READ of {dtype}") + element = np.dtype(_F_READ_DTYPES[dtype]) + raw = conn.handle.read(size * element.itemsize) + if len(raw) < size * element.itemsize: + return (), -1 + parsed = np.frombuffer(raw, dtype=element) + values.append(element.type(parsed[0]) if count is None else parsed.copy()) + return tuple(values), 0 + + +def _f_next_record(conn: Any) -> Any: + """The next record, or None at end of file.""" + start = conn.handle.tell() + line = conn.handle.readline() + if line == "": + return None + conn.starts.append(start) + return line.rstrip("\n").rstrip("\r") + + +def _f_list_tokens(text: str) -> list[str]: + """One record's list-directed values. Blanks and commas separate them, + quotes group them, ``r*v`` repeats one.""" + tokens: list[str] = [] + current, quote = "", "" + for ch in text: + if quote: + if ch == quote: + tokens.append(current) + current, quote = "", "" + else: + current += ch + elif ch in "'\"": + quote = ch + elif ch in " \t,": + if current: + tokens.append(current) + current = "" + else: + current += ch + if current: + tokens.append(current) + expanded: list[str] = [] + for token in tokens: + repeat = _F_REPEAT.fullmatch(token) + if repeat and repeat.group(2): + expanded.extend([repeat.group(2)] * int(repeat.group(1))) + else: + expanded.append(token) + return expanded + + +def _f_read_list(conn: Any, items: Any) -> tuple[Any, int]: + """A list-directed READ: values, across as many records as it takes.""" + needed = sum(1 if count is None else int(count) for _d, count, _w in items) + tokens: list[str] = [] + while len(tokens) < needed: + if conn.record is None: + record = _f_next_record(conn) + if record is None: + return (), -1 # IOSTAT_END + conn.record, conn.column = record, 0 + tokens.extend(_f_list_tokens(conn.record[conn.column :])) + conn.column = len(conn.record) + if len(tokens) < needed: + conn.record = None # the item list is not satisfied: read on + conn.record, conn.column = None, 0 # a list-directed READ advances + return _f_group(items, tokens[:needed]), 0 + + +def _f_read_formatted(conn: Any, fmt: str, items: Any, nonadvancing: bool) -> tuple[Any, int]: + """A formatted READ, over the edit descriptors ``_f_fmt_write`` writes.""" + body = fmt.strip() + if body.startswith("(") and body.endswith(")"): + body = body[1:-1] + slots = [ + (dtype, width) + for dtype, count, width in items + for _ in range(1 if count is None else int(count)) + ] + if conn.record is None: + record = _f_next_record(conn) + if record is None: + return (), -1 + conn.record, conn.column = record, 0 + values: list[Any] = [] + pos = 0 + while len(values) < len(slots): + if pos >= len(body): + # The format is exhausted before the item list: Fortran starts it + # again on the next record. + record = _f_next_record(conn) + if record is None: + return (), -1 + conn.record, conn.column, pos = record, 0, 0 + if not body: + raise ValueError(f"_f_read: format {fmt!r} has no edit descriptor") + match = _FMT_TOKEN.match(body, pos) + if not match or match.end() == pos: + raise ValueError(f"_f_read: cannot parse {fmt!r}") + pos = match.end() + repeat = int(match.group("rep")) if match.group("rep") else 1 + edit = match.group("ed") + upper = edit.upper() + for _ in range(repeat): + if upper.startswith(("'", '"')): + conn.column += len(edit) - 2 + elif upper.endswith("X"): + conn.column += int(upper[:-1]) if upper[:-1] else 1 + elif upper == "/": + record = _f_next_record(conn) + if record is None: + return (), -1 + conn.record, conn.column = record, 0 + elif len(values) < len(slots): + if nonadvancing and conn.column >= len(conn.record): + # End of record: the file is positioned after it, and the + # loop that reads a record character by character stops + # here rather than running into the next one. + conn.record, conn.column = None, 0 + return (), -2 # IOSTAT_EOR + values.append(_f_take_field(conn, upper, slots[len(values)][1])) + if not nonadvancing: + conn.record, conn.column = None, 0 + return _f_group(items, values), 0 + + +def _f_take_field(conn: Any, upper: str, width: Any) -> str: + """The characters one data edit descriptor consumes, blank-padded (the + default PAD='YES') when the record ends inside the field.""" + if upper[0] == "A": + rest = len(conn.record) - conn.column + size = int(upper[1:]) if len(upper) > 1 else (int(width) if width else rest) + else: + spec = upper[2:] if upper.startswith(("ES", "EN")) else upper[1:] + size = int(spec.split(".")[0].split("E")[0]) + field = str(conn.record[conn.column : conn.column + size]) + conn.column += size + return field.ljust(size) + + +def _f_read_value(field: str, dtype: Any) -> Any: + """One field, as the value its item's declared type gives it.""" + text = field.strip() + if dtype == "str": + return field + if dtype == "bool": + return np.bool_(text[:1].upper() == "T" or text[:2].upper() == ".T") + if not text: + return _F_READ_DTYPES.get(dtype, np.float64)(0) + if dtype in ("int32", "int64"): + return _F_READ_DTYPES[dtype](int(float(text.replace("d", "e").replace("D", "e")))) + return _F_READ_DTYPES.get(dtype, np.float64)(float(text.replace("d", "e").replace("D", "e"))) + + +def _f_group(items: Any, fields: Any) -> tuple[Any, ...]: + """The fields read, grouped back onto the items that asked for them.""" + grouped: list[Any] = [] + at = 0 + for dtype, count, _width in items: + if count is None: + grouped.append(_f_read_value(fields[at], dtype)) + at += 1 + else: + size = int(count) + taken = [_f_read_value(f, dtype) for f in fields[at : at + size]] + at += size + grouped.append( + np.array(taken, dtype=object if dtype == "str" else _F_READ_DTYPES[dtype]) + ) + return tuple(grouped) + + def emit() -> str: """This module's runtime definitions, as source text for a generated file. diff --git a/src/recast/transform/numpy/statements.py b/src/recast/transform/numpy/statements.py index 38c8d46..44494ae 100644 --- a/src/recast/transform/numpy/statements.py +++ b/src/recast/transform/numpy/statements.py @@ -20,9 +20,19 @@ * An ``intent(out)`` argument does not exist on the target side: the callee returns it, and the call site assigns it back -- into the buffer, for an array, because the caller may be aliasing it. - -Anything else -- a computed goto, a formatted internal write, an ELSEWHERE -with its own mask -- raises ``NoRule`` and becomes a deferred site. +* An I/O statement that writes a variable -- READ into its item list, INQUIRE + into its specifiers, OPEN into NEWUNIT= -- is translated through the + runtime's unit table rather than stubbed: a ``pass`` drops those writes and + tells the read/write gate that nothing happened. So is a WRITE to a unit an + OPEN in the same body connected to a file: what it writes is a file rather + than a variable, but for a subprogram whose only product is that file the + stub is not a lossy translation, it is an empty one. A WRITE anywhere else + -- ``*``, a bare unit number, a unit the caller connected -- is a log, and + is the stub it always was. + +Anything else -- a computed goto, an edit descriptor the runtime does not +implement, an ELSEWHERE with its own mask -- raises ``NoRule`` and becomes a +deferred site. Refusal here has two spellings, ``NoRule`` from this layer and its rules, and ``Unanalyzable`` out of ``semantics``; ``REFUSED`` is both, and is what the @@ -40,7 +50,7 @@ from recast.fortran.interface import CONFLICTING_BOUNDS, emit_name from recast.fortran.semantics import Semantics, Unanalyzable from recast.transform.numpy.calls import CallSite -from recast.transform.numpy.expressions import REFUSED, Expressions +from recast.transform.numpy.expressions import REFUSED, Expressions, function_outputs from recast.transform.numpy.names import Names from recast.transform.numpy.vocabulary import pysafe from recast.transform.rules import NoRule @@ -163,8 +173,44 @@ def derived_array(type_name: str, extents: list[str], known: dict[str, Any]) -> r"|'[^']*'|\"[^\"]*\"))\s*(?:,\s*)?)*\)", re.I, ) -"""The edit descriptors ``_f_fmt_write`` implements. A formatted internal -write using anything else is refused rather than silently list-directed.""" +"""The edit descriptors ``_f_fmt_write`` and ``_f_read`` implement. A +formatted transfer using anything else is refused rather than silently +list-directed.""" + +READ_DTYPES = frozenset({"float64", "float32", "int32", "int64", "bool", "str"}) +"""Item types ``_f_read`` can parse a field into. A derived type or a +complex is refused: guessing at its input form would put wrong numbers in +the right variables.""" + +OPEN_SPECS = frozenset({"FILE", "STATUS", "ACCESS", "FORM", "POSITION", "ACTION", "RECL"}) +"""OPEN specifiers the runtime honours. Anything else -- ERR=, ASYNCHRONOUS=, +a CONVERT= the connection would have to reinterpret every record through -- +is refused by name.""" + +INQUIRE_SPECS = frozenset( + { + "OPENED", + "EXIST", + "NAMED", + "NAME", + "NUMBER", + "SIZE", + "POS", + "IOSTAT", + "IOMSG", + "FORM", + "ACCESS", + "ACTION", + "POSITION", + "SEQUENTIAL", + "DIRECT", + "FORMATTED", + "UNFORMATTED", + "RECL", + "NEXTREC", + } +) +"""INQUIRE output specifiers ``_f_inquire`` can answer.""" def _loops_whose_index_is_read_after(subprogram: Any) -> set[int]: @@ -263,11 +309,19 @@ def arm_end(node: Any) -> int | None: certain = nesting(statement) <= depth if isinstance(statement, (f03.Nonlabel_Do_Stmt, f03.Label_Do_Stmt)): if index_of(statement) == variable: - if certain: + # A later ``do`` over the same variable is judged by its + # *construct*'s nesting, not the header statement's: the + # header always sits one level inside its own construct, so + # ``nesting(statement)`` would read a sibling loop as deep + # and never let it close the question. The construct at this + # loop's level or above certainly redefines the index before + # the read; one inside an IF arm does not. + construct = getattr(statement, "parent", statement) + if nesting(construct) <= depth: break # redefined by the next loop over it # A deeper loop over the same variable: what its body # reads is its own index. Resume after it. - _, inner_end = node_span(getattr(statement, "parent", statement)) + _, inner_end = node_span(construct) skip_until = inner_end or span[0] continue # Another loop's header may still read it in its bounds @@ -343,8 +397,7 @@ class Statements: """DO constructs (by node id) whose index a later statement reads. Filled by ``scan``. Fortran leaves a completed loop's index one step past the end; Python's ``for`` leaves the last value, so these loops get an - ``else`` that sets the completion value (an EXIT, a ``break``, skips it, - as Fortran keeps the exit value).""" + ``else`` that sets the completion value.""" exit_labels: dict[int, str] = field(default_factory=dict) """``id(do-construct)`` -> the label that means ``exit`` inside it.""" @@ -505,7 +558,7 @@ def render(self, node: Any, indent: int) -> list[str]: if isinstance(node, f03.Format_Stmt): return [f"{pad}pass # FORMAT statement (declarative)"] if isinstance(node, f03.Print_Stmt): - return [f"{pad}pass # PRINT (diagnostic only, no dataflow)"] + return self._print(node, pad) if isinstance(node, (f03.Stop_Stmt, f08.Error_Stop_Stmt)): # ERROR STOP differs from STOP only in the exit status a compiler # is asked to produce; both end the program with the same message, @@ -521,37 +574,18 @@ def render(self, node: Any, indent: int) -> list[str]: # statement itself does nothing where it stands. return [f"{pad}pass # ENTRY (legacy)"] if isinstance(node, f03.Read_Stmt): - # READ writes every item in its list; a pass drops those writes - # silently -- the exact hazard the INQUIRE branch below refuses - # over. Same statement class, same answer. - raise NoRule("READ writes its item list; an I/O stub would drop the writes") - if isinstance(node, (f03.Open_Stmt, f03.Close_Stmt)): - return [f"{pad}pass # OPEN/CLOSE (I/O stub)"] + return self._read(node, pad) + if isinstance(node, f03.Open_Stmt): + return self._open(node, pad) + if isinstance(node, f03.Close_Stmt): + return self._close(node, pad) if isinstance( node, (f03.Rewind_Stmt, f03.Backspace_Stmt, f03.Endfile_Stmt, f03.Flush_Stmt), ): - # File positioning. Unlike INQUIRE below, none of these writes a - # variable, so there is nothing for a read/write gate to compare - # and nothing to lose by dropping them. - return [f"{pad}pass # {type(node).__name__[:-5].upper()} (I/O stub)"] + return self._position(node, pad) if isinstance(node, f03.Inquire_Stmt): - # Not a stub, on purpose, and this is where the pipeline this was - # migrated from differs: it stubs INQUIRE to ``pass``. Every - # output specifier -- ``opened=``, ``pos=``, ``iostat=`` -- is a - # write, and a ``pass`` drops it silently, leaving the variable at - # whatever it held. - written = sorted( - str(spec.children[0]).upper() - for spec in walk(node, f03.Connect_Spec | f03.Inquire_Spec) - if spec.children[0] is not None - and str(spec.children[0]).upper() not in ("UNIT", "FILE") - ) - raise NoRule( - "inquire writes " + ", ".join(f"{k}=" for k in written) - if written - else "inquire with no output specifier" - ) + return self._inquire(node, pad) if isinstance(node, (f03.Forall_Construct, f03.Forall_Stmt)): return self._forall(node, indent) if isinstance(node, f03.Data_Stmt): @@ -694,6 +728,11 @@ def _assignment(self, node: Any, indent: int) -> list[str]: f"{pad}def {pysafe(root)}({formals}): # statement function", f"{pad} return {self.expressions.render(value)}", ] + if self._function_with_outputs(value) is not None: + # ``alpha = linmin(mode, ..., ldat%a, ...)``: the callee hands its + # 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) rendered = self.expressions.render(value) if ( self.semantics.is_scalar_integer_target(target) @@ -710,9 +749,30 @@ 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"int({rendered})" + rendered = f"_f_int({rendered})" return [f"{pad}{self.target(target)} = {rendered}"] + def _function_with_outputs(self, node: Any) -> dict[str, Any] | None: + """The record of a function with source whose OUT/INOUT dummies the + reference ``node`` has to carry back, or ``None`` for any other + right-hand side (``function_outputs``).""" + if not isinstance(node, (f03.Part_Ref, f03.Function_Reference)): + return None + name = str(node.children[0]).lower() + if ( + self.semantics.is_array(name) + or name in self.expressions.function_transforms + or name in self.expressions.stubs + or name in self.expressions.statement_functions + ): + return None + record = self.semantics.procedures.get(name) + if record is None and name in self.expressions.remotes: + record = self.semantics.procedures.get(self.expressions.remotes[name].name) + if record is None or not function_outputs(record): + return None + return record + def _rhs_is_opaque(self, node: Any) -> bool: """Whether a stub, a domain transform or a foreign module decides the right-hand side's Python type, rather than Fortran typing. @@ -1037,20 +1097,38 @@ def _nullify(self, node: Any, pad: str) -> list[str]: # -- I/O ------------------------------------------------------------------ def _write(self, node: Any, indent: int) -> list[str]: - """Log writes (``*``, unit numbers, use-imported units) carry no - comparable dataflow and become ``pass``; a list-directed INTERNAL - write -- the unit is a local character variable -- carries real - dataflow and becomes ``_f_list_write``.""" + """An INTERNAL write -- the unit is a character variable -- assigns + that variable. An external one is split by which unit it writes to: + a unit an OPEN in this body connected to a file gets ``_f_write``, + which puts the records in the file the translated OPEN created; + everything else (``*``, a bare unit number, a unit the caller + connected) is a log and stays the ``pass`` it has always been. + + The split is what a subprogram whose only product is its file needs. + Stubbing ``saveppm``'s writes did not lose detail from the + translation, it lost the translation: the emitted routine opened a + file, wrote nothing to it and returned, and no differential can + compare that against anything.""" pad = " " * indent control, items = node.children specifiers = list(control.children) if hasattr(control, "children") else [] unit = format_ = None + advance = None + record_bound = True position = 0 for specifier in specifiers: keyword, value = specifier.children key = str(keyword).upper() if keyword is not None else None - if key in ("IOSTAT", "ERR", "ADVANCE", "REC"): + if key in ("IOSTAT", "ERR", "REC"): raise NoRule(f"write with {key}= control") + if key == "ADVANCE": + # ADVANCE='no' says the record does not end here, which is a + # property of the file the statement writes: it is carried to + # ``_f_write``. An *internal* write has one record and nowhere + # to carry it, so it is refused further down rather than + # quietly ignored. + advance = self.expressions.render(value) + record_bound = str(value).strip("'\"").lower() != "no" if key == "UNIT" or (key is None and position == 0): unit = value elif key == "FMT" or (key is None and position == 1): @@ -1058,7 +1136,17 @@ def _write(self, node: Any, indent: int) -> list[str]: position += 1 name = str(unit).strip().lower() if unit is not None else "*" declaration = self.semantics.declaration(name) if UNIT_NAME.fullmatch(name) else None - if declaration is not None and declaration.get("dtype") == "str": + subprogram = self.semantics.subprogram + # A character function's result is an internal unit too: ``write(str, + # '(i0)') i`` is how ``str`` returns anything at all, and the result + # variable is declared by the function statement rather than by a + # declaration ``Semantics`` has a record for. + internal = (declaration is not None and declaration.get("dtype") == "str") or ( + name == subprogram.get("result") and subprogram.get("result_dtype") == "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]) @@ -1067,16 +1155,253 @@ def _write(self, node: Any, indent: int) -> list[str]: 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). - if isinstance(format_, f03.Char_Literal_Constant): - text = str(format_)[1:-1] - if not FORMAT_SUPPORTED.fullmatch(text.strip()): - raise NoRule( - f"formatted internal write: unsupported edit descriptor in {text!r}" - ) - return [f"{pad}{pysafe(name)} = _f_fmt_write({text!r}, {arguments})"] - raise NoRule("formatted internal write with a non-literal format") - return [f"{pad}pass # write({name},...) log — no dataflow"] + # 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})"] + if name not in set(subprogram.get("file_units") or ()): + return [f"{pad}pass # write({name},...) log — no dataflow"] + rendered_items = [ + self.expressions.render(item) + for item in (items.children if hasattr(items, "children") else [items]) + if item is not None + ] + settings = "" if advance is None else f", advance={advance}" + return [ + f"{pad}_f_write({self.expressions.render(unit)}, " + f"{self._io_format(format_, 'WRITE')}, [{', '.join(rendered_items)}]{settings})" + ] + + 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 + read, which is the same silent drop READ and INQUIRE refuse over.""" + format_, items = node.children + arguments = [self._io_format(format_, "PRINT")] + arguments += [ + self.expressions.render(item) + for item in (items.children if hasattr(items, "children") else [items]) + if item is not None + ] + return [f"{pad}_f_print({', '.join(arguments)})"] + + def _io_format(self, format_: Any, statement: str) -> str: + """The FMT= of an I/O statement, as the runtime takes it: ``None`` + for list-directed, a checked literal, or an expression evaluated + where it stands.""" + if format_ is None: + return "None" + spelled = str(format_).strip() + if spelled == "*": + return "None" + if isinstance(format_, f03.Char_Literal_Constant): + text = str(format_)[1:-1] + if not FORMAT_SUPPORTED.fullmatch(text.strip()): + raise NoRule(f"{statement}: unsupported edit descriptor in {text!r}") + return repr(text) + if spelled.isdigit(): + # A statement label naming a FORMAT statement elsewhere. The + # emitter renders FORMAT as a declarative pass, so there is + # nothing here to point at. + raise NoRule(f"{statement} with a labelled FORMAT ({spelled})") + return self.expressions.render(format_) + + @staticmethod + def _io_specifiers(container: Any) -> list[tuple[str | None, Any]]: + """``[(KEYWORD or None, value), ...]`` of one specifier list.""" + if container is None: + return [] + children = container.children if hasattr(container, "children") else [container] + specs = [] + for child in children: + keyword, value = child.children + specs.append((str(keyword).upper() if keyword is not None else None, value)) + return specs + + def _io_target(self, value: Any) -> str: + """A specifier that writes: rendered as an assignment target.""" + if isinstance(value, f03.Name): + return self.names.symbol(str(value)) + return self.expressions.render(value) + + def _read(self, node: Any, pad: str) -> list[str]: + """READ: one call that returns the iostat and every item it read. + + Translated rather than stubbed because every item in the list is a + write, and the values have to come from the file the Fortran reads. + """ + control, _label, items = node.children + if control is None: + raise NoRule("READ from the standard input") + unit = format_ = None + advance = None + position_ = None + iostat = None + for position, (key, value) in enumerate(self._io_specifiers(control)): + if key == "UNIT" or (key is None and position == 0): + unit = value + elif key == "FMT" or (key is None and position == 1): + format_ = value + elif key == "ADVANCE": + advance = self.expressions.render(value) + elif key == "POS": + # Stream access: where in the file to read from, counted in + # bytes from one. The runtime seeks there first, so this is + # the statement's own read of whatever computed the offset. + position_ = self.expressions.render(value) + elif key == "IOSTAT": + iostat = self._io_target(value) + else: + raise NoRule(f"READ with {key}= control") + if unit is None: + raise NoRule("READ with no unit") + name = str(unit).strip().lower() + declared = self.semantics.declaration(name) if UNIT_NAME.fullmatch(name) else None + if declared is not None and declared.get("dtype") == "str": + raise NoRule("internal READ: the unit is a character variable") + targets, specs = [], [] + for item in items.children if hasattr(items, "children") else [items]: + target, spec = self._read_item(item) + targets.append(target) + specs.append(spec) + if not targets: + raise NoRule("READ with no item list") + settings = "" if advance is None else f", advance={advance}" + settings += "" if position_ is None else f", pos={position_}" + settings += "" if iostat is None else ", strict=False" + call = ( + f"_f_read({self.expressions.render(unit)}, {self._io_format(format_, 'READ')}, " + f"[{', '.join(specs)}]{settings})" + ) + return [f"{pad}{', '.join([iostat or '_', *targets])} = {call}"] + + def _read_item(self, item: Any) -> tuple[str, str]: + """``(assignment target, item spec)`` for one input item. + + The spec is ``(dtype, count, width)``: how the runtime parses the + field, how many values the item takes, and the declared character + length an ``A`` descriptor needs. An array item reads its own extent + to know the count, which is what the source side records too. + """ + if isinstance(item, f03.Name): + name = str(item).lower() + elif isinstance(item, f03.Part_Ref): + name = str(item.children[0]).lower() + else: + raise NoRule(f"READ item {str(item)!r} is not a variable") + declared = self.semantics.declaration(name) + if declared is None: + raise NoRule(f"READ into {name!r}, which is not declared here") + dtype = str(declared.get("dtype")) + if dtype not in READ_DTYPES: + raise NoRule(f"READ into {name!r}, of type {dtype}") + length = str(declared.get("char_len") or "").strip() + width = length if length.isdigit() else ("1" if dtype == "str" and not length else "None") + rendered = ( + self.names.symbol(name) if isinstance(item, f03.Name) else self.expressions.render(item) + ) + if self.semantics.rank(item) == 0: + return rendered, f"({dtype!r}, None, {width})" + target = f"{rendered}[...]" if isinstance(item, f03.Name) else rendered + return target, f"({dtype!r}, np.size({rendered}), {width})" + + def _open(self, node: Any, pad: str) -> list[str]: + """OPEN: a connection the READs below it read through. + + ``newunit=`` and ``iostat=`` are writes, so this is not a stub; the + rest of the specifiers are passed to the runtime as they stand. + """ + arguments: dict[str, str] = {} + unit = "None" + newunit = iostat = None + for position, (key, value) in enumerate(self._io_specifiers(node.children[1])): + if key == "UNIT" or (key is None and position == 0): + unit = self.expressions.render(value) + elif key == "NEWUNIT": + newunit = self._io_target(value) + elif key == "IOSTAT": + iostat = self._io_target(value) + elif key in OPEN_SPECS: + if key == "STATUS" and str(value).strip("'\"").lower() == "scratch": + raise NoRule("OPEN with STATUS='SCRATCH'") + arguments[key.lower()] = self.expressions.render(value) + else: + raise NoRule(f"OPEN with {key}= specifier") + spelled = ", ".join( + [unit, arguments.pop("file", "None")] + + [f"{key}={value}" for key, value in sorted(arguments.items())] + + ([] if iostat is None else ["strict=False"]) + ) + if newunit is None and iostat is None: + return [f"{pad}_f_open({spelled})"] + return [f"{pad}{iostat or '_'}, {newunit or '_'} = _f_open({spelled})"] + + def _close(self, node: Any, pad: str) -> list[str]: + arguments, iostat = self._unit_and_iostat(node.children[1], "CLOSE", {"STATUS"}) + return self._unit_call("_f_close", arguments, iostat, pad) + + def _position(self, node: Any, pad: str) -> list[str]: + """REWIND, BACKSPACE, ENDFILE, FLUSH: the file position is state the + READs around them read, so each one moves it for real.""" + shim = f"_f_{type(node).__name__[:-5].lower()}" + if node.children[0] is not None and not hasattr(node.children[0], "items"): + return [f"{pad}{shim}({self.expressions.render(node.children[0])})"] + arguments, iostat = self._unit_and_iostat(node.children[1], type(node).__name__[:-5], set()) + return self._unit_call(shim, arguments, iostat, pad) + + def _unit_and_iostat( + self, container: Any, statement: str, allowed: set[str] + ) -> tuple[list[str], str | None]: + """The unit, the specifiers ``allowed``, and the IOSTAT= target.""" + arguments: list[str] = [] + iostat = None + for position, (key, value) in enumerate(self._io_specifiers(container)): + if key == "UNIT" or (key is None and position == 0): + arguments.insert(0, self.expressions.render(value)) + elif key == "IOSTAT": + iostat = self._io_target(value) + elif key in allowed: + arguments.append(f"{key.lower()}={self.expressions.render(value)}") + else: + raise NoRule(f"{statement} with {key}= specifier") + if not arguments: + raise NoRule(f"{statement} with no unit") + return arguments, iostat + + @staticmethod + def _unit_call(shim: str, arguments: list[str], iostat: str | None, pad: str) -> list[str]: + if iostat is not None: + arguments.append("strict=False") + return [f"{pad}{iostat} = {shim}({', '.join(arguments)})"] + return [f"{pad}{shim}({', '.join(arguments)})"] + + def _inquire(self, node: Any, pad: str) -> list[str]: + """INQUIRE: one assignment per output specifier. + + Every specifier but UNIT= and FILE= is a write, which is why this was + refused rather than stubbed; a specifier the runtime can answer is + now translated, and one it cannot is still refused by name. + """ + if node.children[1] is not None or node.children[2] is not None: + raise NoRule("INQUIRE(IOLENGTH=...)") + unit = "None" + file = "None" + outputs: list[tuple[str, str]] = [] + for position, (key, value) in enumerate(self._io_specifiers(node.children[0])): + if key == "UNIT" or (key is None and position == 0): + unit = self.expressions.render(value) + elif key == "FILE": + file = self.expressions.render(value) + elif key in INQUIRE_SPECS: + outputs.append((key.lower(), self._io_target(value))) + else: + raise NoRule(f"inquire writes {key}=") + if not outputs: + raise NoRule("inquire with no output specifier") + return [f"{pad}{target} = _f_inquire({unit}, {file}, {key!r})" for key, target in outputs] # -- control flow --------------------------------------------------------- @@ -1570,10 +1895,40 @@ def _do_construct_inner( high = self.expressions.render(bounds[1]) step = self.expressions.render(bounds[2]) if len(bounds) > 2 else None name = pysafe(str(variable).lower()) + # Fortran leaves the index one step past the last iteration when the + # loop runs to completion (and at its start value when it never + # runs); Python leaves it at the last iteration. Where the body + # reads the index afterwards -- ``do j=1,n; if (...) exit; end do; + # k = j-1``, hfti's pseudorank -- the loop gets an ``else`` that sets + # the completion value. The bounds are evaluated once, at entry: a + # body that writes a name they use gets them held in temporaries. + hoisted: list[str] = [] + # The step's spelling decides the range's stop edge below, so it is + # judged as written even when its value is held in a temporary. + spelled_step = step + if id(node) in self.index_read_after: + written = self._written_in_body(node) + if any(str(n).lower() in written for b in bounds for n in walk(b, f03.Name)): + held = {"lo": low, "hi": high, "st": step} + for key, text in held.items(): + if text is not None: + hoisted.append(f"{pad}_do{key}_{name} = {text}") + low, high = f"_dolo_{name}", f"_dohi_{name}" + step = f"_dost_{name}" if step is not None else None + # One unified completion, whatever the step: the trip count times + # the step, past the low bound. ``increment`` is the step or 1, so + # a unit-step loop is ``(low) + max(0, high - low + 1) * (1)`` -- + # the same ``max(0, ...)`` form as a stepped one, never negative. + increment = step if step is not None else "1" + trips = f"max(0, (({high}) - ({low}) + ({increment})) // ({increment}))" + completion = f"({low}) + {trips} * ({increment})" + tail = [f"{pad}else:", f"{pad} {name} = {completion}"] + else: + tail = [] if step is None: head = f"{pad}for {name} in range({low}, {high} + 1):" else: - text = step.lstrip("(").lstrip() + text = (spelled_step or step).lstrip("(").lstrip() if text.startswith("-") and not any(c.isalpha() or c == "_" for c in text[1:2]): # A literal negative step: the stop edge is known at compile time. head = f"{pad}for {name} in range({low}, {high} - 1, {step}):" @@ -1587,18 +1942,27 @@ def _do_construct_inner( f"{pad}for {name} in range({low}, " f"({high}) + (1 if ({step}) > 0 else -1), {step}):" ) - lines = [head, *self._loop_body(node, indent, cycle_name)] - if id(node) in self.index_read_after: - # CLUBB's lscale_width_vert_avg searches with ``do k_avg_upper = - # k, ...; if (...) exit; end do`` and then integrates up to - # k_avg_upper: on completion Fortran's index is the first value - # past the end, m1 + n * m3, and ``for``'s is the last one. - # ``else`` runs exactly when no ``break`` did. - increment = step if step is not None else "1" - trips = f"max(0, (({high}) - ({low}) + ({increment})) // ({increment}))" - lines.append(f"{pad}else:") - lines.append(f"{pad} {name} = ({low}) + {trips} * ({increment})") - return lines + return [*hoisted, head, *self._loop_body(node, indent, cycle_name), *tail] + + @staticmethod + def _written_in_body(node: Any) -> set[str]: + """Names a DO body may assign: assignment targets and call actuals.""" + written: set[str] = set() + for child in node.children: + if isinstance(child, (f03.Nonlabel_Do_Stmt, f03.Label_Do_Stmt, f03.End_Do_Stmt)): + continue + for statement in walk(child, f03.Assignment_Stmt): + target = statement.children[0] + base = ( + target + if isinstance(target, f03.Name) + else next(iter(walk(target, f03.Name)), None) + ) + if base is not None: + written.add(str(base).lower()) + for call in walk(child, f03.Call_Stmt): + written.update(str(n).lower() for n in walk(call, f03.Name)) + return written def _caught_cycle(self, body: list[str], indent: int, cycle_name: str | None) -> list[str]: """A loop body, wrapped so a CYCLE naming *this* loop reaches its header.""" @@ -1785,7 +2149,11 @@ def _procedure_dummy_interface(self, name: str) -> dict[str, Any] | None: return None return {**interface, "name": name} - def _call(self, node: Any, indent: int) -> list[str]: + def _call(self, node: Any, indent: int, result: Any = None) -> list[str]: + """A CALL statement -- or, with ``result``, an assignment whose + right-hand side is a reference to a function that hands OUT/INOUT + dummies back beside its result (``function_outputs``): ``result`` is + the assignment's target, unpacked first, the dummies after it.""" pad = " " * indent items = list(node.children[1].children) if node.children[1] is not None else [] designator = node.children[0] @@ -1843,7 +2211,12 @@ def _call(self, node: Any, indent: int) -> list[str]: if name in self.semantics.generics: name = self.semantics.dispatch(name, items) record = self.semantics.procedures.get(name) - if record is not None and record not in self.semantics.module["subprograms"]: + declared_here = (self.semantics.module.get("interfaces") or {}).values() + if ( + record is not None + and record not in self.semantics.module["subprograms"] + and not any(record is declared for declared in declared_here) + ): record = None # a companion's; resolved below through remotes prefix = "" if record is None and name in self.semantics.companion_generics: @@ -1965,9 +2338,27 @@ def _call(self, node: Any, indent: int) -> list[str]: except REFUSED: pass # Host association: an internal callee takes the host variables it - # touches as extra trailing actuals. + # touches as extra trailing actuals, and hands back the ones it + # changes after its declared outputs (``Statements.returned_value``). + # A bare name as the target, an array included: the callee mutated + # the caller's array in place and returns that same object, or + # rebound its parameter to a new one, and rebinding here is right + # either way. for host_var in record.get("host_vars") or (): inputs.append(self.names.symbol(host_var)) + for host_var in record.get("host_writes") or (): + outputs.append(self.names.symbol(host_var)) + if result is not None: + if record.get("host_writes"): + raise NoRule( + f"internal function {name} writes host variable(s) " + f"{', '.join(record['host_writes'])}, which a function reference " + "cannot carry back" + ) + # The result comes first in the callee's tuple; the outputs + # bound above follow it, so their positions move up by one. + outputs.insert(0, self.target(result)) + flattened = {position + 1 for position in flattened} # 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] @@ -1983,18 +2374,25 @@ def _call(self, node: Any, indent: int) -> list[str]: def value(index: int, text: str) -> str: return f"np.ravel({text}, order='F')" if index in flattened else text - has_array = any("[...]" in target for target in outputs) or flattened + def copy_out(target: str, text: str) -> str: + # A target spelled with ``{}`` is a runtime write-back -- + # ``_f_seq_tail_out(a, start, {})`` -- that takes the value + # itself, where a buffer target takes it through ``_f_copy_out``. + if "{}" in target: + return f"{pad}{target.format(text)}" + return f"{pad}_f_copy_out({target.replace('[...]', '')}, {text})" + + def copied(index: int, target: str) -> bool: + return "[...]" in target or "{}" in target or index in flattened + + has_array = any(copied(i, target) for i, target in enumerate(outputs)) if has_array and len(outputs) == 1: - base = outputs[0].replace("[...]", "") - return [f"{pad}_f_copy_out({base}, {value(0, call)})"] + return [copy_out(outputs[0], value(0, call))] if has_array: lines = [f"{pad}_out = {call}"] for i, target in enumerate(outputs): - if "[...]" in target or i in flattened: - lines.append( - f"{pad}_f_copy_out({target.replace('[...]', '')}, " - f"{value(i, f'_out[{i}]')})" - ) + if copied(i, target): + lines.append(copy_out(target, value(i, f"_out[{i}]"))) else: lines.append(f"{pad}{target} = _out[{i}]") return lines @@ -2017,6 +2415,18 @@ def _output_target( # A whole-array out actual: assign INTO the buffer, preserving # Fortran's aliasing semantics. if self.semantics.is_array(name): + formal_dims = formal.get("dims") or [] + try: + rank = self.semantics.rank(actual) + except REFUSED: + rank = None + if formal_dims and rank is not None and rank != len(formal_dims): + # A whole array of another rank -- a matrix to ``c(*)``, + # a workspace to ``a(mda, n)``: the callee's array is the + # storage in column-major order, and lands back the same + # way, from the first cell -- not through ``_f_copy_out``, + # whose rank-mismatch path walks the buffer in C order. + return f"_f_seq_tail_out({self.names.symbol(name)}, 0, {{}})", False return f"{self.names.symbol(name)}[...]", False return self.names.symbol(name), False if isinstance(actual, f03.Part_Ref): @@ -2112,8 +2522,17 @@ def is_optional_output(formal: dict[str, Any]) -> bool: def returned_value(self) -> str: subprogram = self.semantics.subprogram if subprogram["kind"] == "function": - return pysafe(subprogram["result"]) + # A function with a mandatory OUT/INOUT dummy hands it back + # beside its result (``function_outputs``); the reference site + # unpacks the tuple the way a CALL's is unpacked. + carried = [pysafe(a["name"]) for a in function_outputs(subprogram)] + return ", ".join([pysafe(subprogram["result"]), *carried]) outputs = [pysafe(a["name"]) for a in subprogram["args"] if a["intent"] in ("OUT", "INOUT")] + # Host association: a host variable this internal procedure changes + # goes back the way an INOUT dummy does, after the declared outputs. + # It arrived as a trailing parameter under its own name (see + # ``Names.symbol``), so that is the name returned. + outputs.extend(pysafe(hw) for hw in subprogram.get("host_writes") or ()) if not outputs: return "" return ", ".join(outputs) if len(outputs) > 1 else outputs[0] diff --git a/src/recast/transform/numpy/subprograms.py b/src/recast/transform/numpy/subprograms.py index 97136d1..6503eae 100644 --- a/src/recast/transform/numpy/subprograms.py +++ b/src/recast/transform/numpy/subprograms.py @@ -35,6 +35,7 @@ from recast.fortran._parse import f03, walk 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.semantics import Semantics, for_subprogram from recast.transform.numpy.agentic import DeferredHandler, DeferredSite @@ -68,6 +69,44 @@ BOZ_TEXT = re.compile(r"[zboZBO]'([0-9a-fA-F]+)'") 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) + + +def _literal_array(inner: str) -> str | None: + """A ``[ ... ]`` constructor of nothing but literals, rendered element by + element at the precision the compiler evaluated each in, or ``None``. + + The token pass otherwise wrote the constructor text out verbatim, which + turns an unsuffixed real -- Fortran default (single) kind -- into a full + float64: a lookup table like ``dqk61``'s ``wgk = [1.389...e-3, ...]`` then + carries eight digits the reference never had, and the differential gate + sees every point differ. This mirrors the constants module's own + per-element rendering so the two agree; a non-literal element (a name, an + implied do) returns ``None`` and the caller falls back to the token pass. + """ + items = [item.strip() for item in inner.split(",")] + if not items or items == [""]: + return None + rendered: list[str] = [] + integral = True + for item in items: + compact = item.replace(" ", "") + if re.fullmatch(r"'[^']*'", item): + rendered.append(item) + integral = False + elif INTEGER_TEXT.fullmatch(item): + rendered.append(str(int(compact))) + elif REAL_TEXT.fullmatch(item): + base = compact.split("_")[0].replace("d", "e").replace("D", "e") + if is_default_real(compact): + rendered.append(f"np.float64(np.float32('{base}'))") + else: + rendered.append(f"np.float64('{base}')") + integral = False + else: + return None + dtype = ", dtype=np.int32" if integral else "" + return f"np.array([{', '.join(rendered)}]{dtype})" UPPERCASED_CALL = re.compile(r"\b[A-Z_][A-Z0-9_]*\s*\(") @@ -669,6 +708,13 @@ def _result_initializer( shape = ", ".join( statements.bound(d["ub"]) if d.get("ub") else "1" for d in subprogram["result_dims"] ) + # In the result's own dtype, and returned instead of falling + # through to the scalar initializers below. Both halves were wrong + # for ``integer :: b(size(a))``: the buffer came back float64, and + # the ``b = 0`` that the separate int branch below then appended + # rebound the name to a scalar, so the first store into the result + # raised TypeError. allocated_dtype carries the kind through and + # refuses a dtype it cannot map rather than defaulting to float64. try: dtype = allocated_dtype(subprogram["result_dtype"]) except REFUSED as refusal: @@ -677,8 +723,8 @@ def _result_initializer( f" # AGENT_QUEUE: {reason}", f" raise NotImplementedError({reason!r})", ] - lines.append(f" {result} = np.zeros(({shape},), dtype={dtype})") - elif subprogram["result_dtype"] in ("float64", "float32"): + return [f" {result} = np.zeros(({shape},), dtype={dtype})"] + if subprogram["result_dtype"] in ("float64", "float32"): lines.append(f" {result} = 0.0") elif subprogram["result_dtype"] in ("complex128", "complex64"): lines.append(f" {result} = {SCALAR_ZEROS[subprogram['result_dtype']]}") @@ -992,6 +1038,23 @@ def _local( ) if filled is not None: return [f" {name} = {filled}"] + initializer = local.get("init_expr") + if ( + initializer + and local["dtype"] in ("float64", "float32", "int32", "int64") + and str(initializer).strip().startswith("[") + ): + # ``real(wp),dimension(7) :: c = [ ... ]``: a declared array + # whose value is a lookup table, not the UB-guard zeros the + # bare-array branch would leave. Rendered like a local + # parameter so its literals carry the compiler's precision. + own = frozenset(p["name"].lower() for p in semantics.subprogram["local_parameters"]) + try: + value = self._parameter_value(str(initializer).strip(), own, statements) + except REFUSED: + value = None + if value is not None and value.startswith("np.array("): + return [f" {name} = {value} # declared initializer"] try: dtype = allocated_dtype(local["dtype"]) except REFUSED as refusal: @@ -1126,6 +1189,12 @@ def array_of(constructed: re.Match[str]) -> str: return f"np.array([{', '.join(items)}], dtype=np.int32)" return f"np.array([{', '.join(items)}])" + bracketed = NEWFORM_ARRAY.fullmatch(text.strip()) + if bracketed: + literal = _literal_array(bracketed.group(1)) + if literal is not None: + return literal + # A module constant is spelled upper case in the emitted source, and a # reference to one of this subprogram's own parameters is not -- that # one was emitted as a local assignment just above, under its own diff --git a/src/recast/transform/numpy/translate.py b/src/recast/transform/numpy/translate.py index c0ddf5d..ac17aba 100644 --- a/src/recast/transform/numpy/translate.py +++ b/src/recast/transform/numpy/translate.py @@ -10,7 +10,8 @@ The Candidate carries the whole product of a translation: the generated module, its constants module, its use-constants module when the source -imports constants from modules that are not being translated -- and, in +imports constants from modules that are not being translated, the +translation of every sibling module the generated one imports -- and, in ``notes``, the block report and the name-protocol table. The report says which blocks are mechanical and which are deferred, and the deferred list is the agent queue: a partial Candidate with an honest list of what it could @@ -101,6 +102,14 @@ def companion_tables( remotes[local] = Remote(alias, remote) for subprogram in record["subprograms"]: remotes.setdefault(subprogram["name"], Remote(alias, subprogram["name"])) + # A procedure the sibling declares through an INTERFACE block and does + # not define: an interface module over a compiled library. It is + # reached the way every other name of that module is -- through the + # sibling's alias -- because that is where the source says it lives, + # and a translation of the library is not this engine's to invent. + for declared in (record.get("interfaces") or {}).values(): + if declared.get("kind") in ("subroutine", "function"): + remotes.setdefault(declared["name"], Remote(alias, declared["name"])) # A parameter is spelled as the companion's constants file spells it: # upper-case when that file defines it, lower-case when the file has # only a SKIPPED line for it. Without the companion's constants record @@ -411,6 +420,15 @@ def apply(self, unit: Unit, facts: Facts, config: dict[str, Any]) -> Candidate: use["resolved"], use["module_name"] ).encode() + # Every import the header makes has to be answered by a file this + # candidate carries: the gate stages a candidate's own files and + # nothing else. The companions' translations go in first, so a + # sibling that spells a file this module already carries loses to + # the unit's own. + bundled = self._bundle(declared, companion_imports, text, config) + if bundled: + files = {**bundled, **files} + # Emitted name -> source name, per subprogram: the record the # read/write cross-check needs to undo the constant renames. # Producing it is part of this Transform's obligation, not an @@ -443,6 +461,21 @@ def apply(self, unit: Unit, facts: Facts, config: dict[str, Any]) -> Candidate: ) }, "profile": assembler.profile.name, + # Which siblings' translations ride along, for whoever reads + # the candidate; the unit's own files are unaffected. + **( + { + "bundled": sorted( + { + p.stem[: -len("_numpy")] + for p in bundled + if p.name.endswith("_numpy.py") + } + ) + } + if bundled + else {} + ), "source_digest": facts.provenance.get("digest"), "companions": [c["alias"] for c in config.get("companions", [])], "renames": renames, @@ -452,6 +485,94 @@ def apply(self, unit: Unit, facts: Facts, config: dict[str, Any]) -> Candidate: }, ) + def _bundle( + self, + companions: list[dict[str, Any]], + imports: tuple[str, ...], + emitted: str, + config: dict[str, Any], + ) -> dict[Path, bytes]: + """The companions' own translations, to carry in this candidate. + + The emitted header imports ``_numpy`` for every companion the + unit ``use``s, and ``differential.bitexact`` stages a candidate's own + files and nothing else -- so a candidate that names a file it does not + carry raises ``ModuleNotFoundError`` before a single number is + compared. That is what a whole corpus case's siblings did: a kinds-only + ``use types`` and a ``call stop_error`` are enough, and neither is a + tree, an extension or a table away from being translatable. So the + translation of each companion rides along, produced by this same + Transform from the source the frontend resolved the ``use`` to. + + Two companions are left alone, and each for a reason: + + * one the operator declared with a ``module_py`` of its own, which + says the sibling's translation is already deployed under that name + -- the pipeline's arrangement, and not ours to overwrite; + * one whose source this root does not hold, or which does not + translate. Bundling is not the place to decide what that means: the + import it would have answered is left unanswered and the gate fails + the unit on it, saying which module was missing. + + ``bundle_companions: false`` turns it off for a caller that bundles on + its own terms -- ``translate.tree``, which resolves the companions' + use-constants and writes stand-ins beside them. + """ + from recast.registry import REGISTRY + + if not companions or not config.get("bundle_companions", True): + return {} + # Only what the header ended up importing: a ``use`` that brought + # nothing but a kind parameter binds no alias, its import is dropped, + # and translating that sibling would put a file in the candidate + # nothing reads. + wanted = { + module + for module, line in zip(map(_module_of, companions), imports, strict=True) + if f"\n{line}\n" in emitted + } + root = Path(config.get("root", ".")).resolve() + seen: set[str] = set(config.get("_bundled") or ()) + files: dict[Path, bytes] = {} + frontend: Any = None + units: dict[str, Unit] = {} + for companion in companions: + module = _module_of(companion) + if not module or module not in wanted or module in seen: + continue + if companion.get("module_py"): + continue + if not companion.get("source"): + continue + seen.add(module) + if frontend is None: + frontend = REGISTRY.get("frontend", config.get("frontend", "fortran"))() + units = {u.uid: u for u in frontend.discover(root)} + sibling = units.get(f"fortran:{module}") + if sibling is None: + continue + own = { + key: value + for key, value in config.items() + if key + not in ( + "companions", + "use_constants", + "constants_stem", + "use_constants_stem", + "extern_constants", + ) + } + try: + inner = self.apply( + sibling, frontend.analyze(sibling, root), {**own, "_bundled": seen} + ) + except Exception: # noqa: S112 -- the gate reports the import left unanswered + continue + for path, blob in inner.files.items(): + files.setdefault(path, blob) + return files + def _rwset_protocol( self, unit: Unit, @@ -469,6 +590,7 @@ def _rwset_protocol( ``raise NotImplementedError`` is not a translation, and the gate's job is to judge translations. """ + from recast.fortran.interface import emit_name from recast.transform.numpy.vocabulary import RESERVED, pysafe blocks = [] @@ -515,6 +637,18 @@ def _rwset_protocol( "names": names, "procedures": sorted( {pysafe(record["name"]) for record in facts.interface["subprograms"]} + # ...under the name the file defines them by as well: two hosts' + # ``func`` come out as ``host__func``, and the bare name alone + # left every call to one counted as a read of the callee. + | {pysafe(emit_name(record)) for record in facts.interface["subprograms"]} + # A procedure this module declares through an INTERFACE block + # and defines nowhere binds like a module procedure and is + # called by its own name; the reference build stubs it. + | { + pysafe(declared["name"]) + for declared in (facts.interface.get("interfaces") or {}).values() + if declared.get("kind") in ("subroutine", "function") + } # The siblings' procedures too: `_wv.wv_sat_svp_water(t)` is a # call, and without these the alias rule would read it as data. | {remote.name for remote in assembler.remotes.values()} diff --git a/src/recast/transform/numpy/vocabulary.py b/src/recast/transform/numpy/vocabulary.py index 14e2e98..43b29a0 100644 --- a/src/recast/transform/numpy/vocabulary.py +++ b/src/recast/transform/numpy/vocabulary.py @@ -38,12 +38,13 @@ ELEMENTAL_SCALAR: dict[str, str] = { "abs": "abs", + "achar": "chr", "acos": "math.acos", "adjustl": "_f_adjustl", "aimag": "np.imag", "aint": "np.trunc", - "alog": "math.log", - "alog10": "math.log10", + "alog": "_f_log", + "alog10": "_f_log10", "amax0": "max", "amin0": "min", "anint": "np.round", @@ -63,8 +64,8 @@ "dcos": "math.cos", "dexp": "math.exp", "dim": "_f_dim", - "dlog": "math.log", - "dlog10": "math.log10", + "dlog": "_f_log", + "dlog10": "_f_log10", "dmax1": "max", "dmin1": "min", "dsin": "math.sin", @@ -83,7 +84,7 @@ "ichar": "ord", "ieor": "_f_ieor", "index": "_f_index", - "int": "int", + "int": "_f_int", "ior": "_f_ior", "is_iostat_end": "_f_is_iostat_end", "isign": "_f_sign", @@ -94,8 +95,8 @@ "lbound": "_f_lbound", "len": "len", "len_trim": "_f_len_trim", - "log": "math.log", - "log10": "math.log10", + "log": "_f_log", + "log10": "_f_log10", "max": "_f_max", "max0": "max", "min": "_f_min", @@ -105,6 +106,7 @@ "mvbits": "_f_mvbits", "nint": "_f_nint", "precision": "_f_precision", + "radix": "_f_radix", "real": "np.float64", "scan": "_f_scan", "shape": "np.shape", @@ -127,6 +129,7 @@ ELEMENTAL_ARRAY: dict[str, str] = { "abs": "np.abs", + "achar": "_f_vachar", "aint": "np.trunc", "anint": "np.round", "ceiling": "_f_vceil", @@ -210,8 +213,32 @@ """Spelled identically, and listed anyway: an operator absent from this table is one the emitter has no rule for, which is the answer it needs.""" -RESERVED: frozenset[str] = frozenset({"_re", "copy", "math", "mp", "np", "os"}) -"""Module aliases the emitted file uses itself. +RESERVED: frozenset[str] = frozenset( + { + # Module aliases the emitted file imports. + "_re", + "copy", + "math", + "mp", + "np", + "os", + # Python builtins the emitter spells bare: ``max(a, b)`` for MAX, + # ``range`` for every DO loop. A Fortran local called ``max`` -- the + # Jenkins-Traub ``scale`` has one -- emitted under its own name would + # shadow the builtin the same function's other lines call, and the + # read/write check, which knows the builtin as the backend's, saw no + # variable at all. + "abs", + "chr", + "complex", + "len", + "max", + "min", + "ord", + "range", + } +) +"""Names the emitted file uses itself: its module aliases and its builtins. A Fortran dummy argument named ``np`` would shadow NumPy in the translation, so it is renamed. Declared here rather than assumed by the verifier: the diff --git a/src/recast/transform/profiles.py b/src/recast/transform/profiles.py index 25df49d..3f00cf6 100644 --- a/src/recast/transform/profiles.py +++ b/src/recast/transform/profiles.py @@ -50,4 +50,7 @@ class Profile: DEFAULT = "ifx" """What the reference builds this grew up on use. An operator comparing against a gfortran -build has to say so; there is no way to detect it from the source.""" +build has to say so; there is no way to detect it from the source. The ``translate`` +recipe says so on their behalf when it can: it declares the profile of the compiler its +golden oracle builds with (``recast.recipes``), and this default is what a transform run +outside that recipe, or against an oracle whose compiler no profile names, falls back to.""" diff --git a/src/recast/verify/bitexact.py b/src/recast/verify/bitexact.py index 3f3d463..c299692 100644 --- a/src/recast/verify/bitexact.py +++ b/src/recast/verify/bitexact.py @@ -22,24 +22,41 @@ table, values from per-name ``ranges``. The physical ranges that make a model kernels behave -- temperatures in kelvin, pressures in pascals -- are domain knowledge and arrive in config; the engine's defaults are only wide, not -wise. Structure that no per-name range can express -- a packed workspace -whose extent is ``n(n+1)/2``, a mode the source stops on, a column that must -be monotone -- comes from the project itself: a ``recast_inputs.py`` at the -root, whose ``prepare(unit, subprogram, inputs, rng)`` shapes each generated -draw before both sides receive it. Subprograms with deferred blocks are -skipped and said so: their translation raises ``NotImplementedError`` by -construction, and the gate's job is to judge translations, not queues. +wise. An extent nobody pinned is the harness's own to choose, so a shape the +body will not take -- a packed workspace whose ``lr`` must be ``n(n+1)/2`` +for the order it goes with -- is grown until the subscripts fit rather than +left to the operator. Structure in the *values* that no per-name range can +express -- a mode the source stops on, a column that must be monotone -- +comes from the project itself: a ``recast_inputs.py`` at the root, whose +``prepare(unit, subprogram, inputs, rng)`` shapes each generated draw before +both sides receive it. Subprograms with deferred blocks are skipped and said +so: their translation raises ``NotImplementedError`` by construction, and the +gate's job is to judge translations, not queues. + +Not every output is an argument. A subprogram whose only product is the file +it writes -- ``saveppm(filename, img)``, which declares two inputs and +nothing else -- has nothing for the comparison to pair, and comparing it on +its arguments would be comparing what the caller already knew. So a character +dummy the source hands to an OPEN that *creates* a file is drawn as a scratch +path, one per side, and the bytes each side left there are compared like any +other declared-integer output (``drawable_path``, ``_file_outputs``). """ from __future__ import annotations import ast +import contextlib import importlib.util +import keyword import operator import re +import shutil +import signal import sys +import tempfile +import threading import types -from collections.abc import Callable, Sequence +from collections.abc import Callable, Iterator, Sequence from pathlib import Path from typing import Any @@ -50,7 +67,7 @@ from recast.plugins.verifier import Verifier from recast.verify.ulp import ulp_audit -__all__ = ["BitexactVerifier", "factory"] +__all__ = ["BitexactVerifier", "factory", "flatten_derived"] DEFAULT_RANGE = (-1000.0, 1000.0) DEFAULT_INTEGER_RANGE = (1, 8) @@ -70,17 +87,6 @@ def _f2py_name(name: str) -> str: return str(dict(crackfortran.badnames).get(lowered, lowered)) -def _passed_buffer(argument: dict[str, Any]) -> bool: - """An OUT array that is the caller's buffer with an axis of no declared - extent: the reference takes it in and writes it in place (the f2py - wrapper spells it ``inout``), so it is read back from what was passed.""" - return bool( - argument.get("intent") == "OUT" - and argument.get("buffer") - and any(not d.get("ub") for d in argument.get("dims") or ()) - ) - - def _bounds_violation(runtime_error: str | None) -> bool: """libgfortran's ``-fcheck=bounds`` diagnostics all name the bound: a subscript ``below lower bound`` or ``above upper bound``, a substring @@ -112,6 +118,17 @@ def _redrawn_note(totals: dict[str, Any]) -> str: DEFAULT_DIMENSION = 8 +GROWTH_FACTORS = (2, 4, 8, 16, 32, 64) +"""What an unpinned extent is multiplied by while a shape is being fitted. + +Multiples of the default rather than a walk upward: the extent a packed +workspace wants grows with the square of the order it goes with, so a search +that adds one at a time never arrives. Sixty-four times the default of eight +covers an order-eight triangle (36) with room over. +""" +MAX_FITTED_EXTENT = 1024 +"""Ceiling on a grown extent, so a subprogram no shape fits costs a bounded +amount of memory rather than the machine's.""" SUPPORTED_DTYPES = frozenset( {"float32", "float64", "int32", "int64", "bool", "complex64", "complex128"} ) @@ -120,6 +137,83 @@ def _redrawn_note(totals: dict[str, Any]) -> str: both parts are, and an ULP distance is a part's. Draws give both parts the argument's range.""" PROCEDURE_DTYPE = "PROCEDURE" + + +class _NegativeSubscript(IndexError): + """A translated subscript computed below a dummy's declared lower bound. + + A positive overrun (``IndexError`` from a plain ndarray) is a shape the + harness's own default got wrong, and growing an unpinned extent can fix + it (see ``_fit_extents``). A negative one is not a shape at all -- no + extent, grown or not, changes whether an index is negative -- it is a + value outside the domain the source itself takes (PCHIP's ``dpchkt`` + forms ``x(n-1)`` and is only ever called with N>=2), so it is drawn + again exactly like an ``ERROR STOP`` or a NaN-inducing value, and left + out of the ``reshaped`` accounting a shape refusal earns. + """ + + +def _reject_negative_subscript(key: Any) -> None: + """Refuse a negative integer subscript; leave slices and arrays alone. + + A translated subscript is always ``expr - lb``: a body that reads a + dummy below its declared lower bound -- PCHIP's ``dpchkt`` forms + ``x(n-1)`` and is only ever called with N>=2, so ``x(0)`` is a draw + outside the source's own domain, not a shape this harness chose -- + computes a negative Python index. Plain ndarray wraps that to the + *other* end of the array instead of refusing it the way a positive + overrun already does (an ``IndexError`` the redraw loop below already + knows how to answer without ever calling the reference on it), so the + candidate would silently read the wrong element instead of raising. + """ + indices = key if isinstance(key, tuple) else (key,) + for index in indices: + try: + value = operator.index(index) + except TypeError: + continue # a slice, a mask, a fancy index -- not a bare subscript + if value < 0: + raise _NegativeSubscript( + f"index {value} is out of bounds for a Fortran dummy " + "(subscript below its declared lower bound)" + ) + + +_NO_WRAP_ARRAY_TYPES: dict[int, type] = {} +"""One ``_NoWrapArray`` class per ``np`` module handed in, built lazily. + +``numpy`` is imported lazily throughout this file, so nothing here can +subclass ``np.ndarray`` at module scope; a subclass is built once per +``np`` (keyed by ``id()``, since a project's own ``np`` and any test +double share nothing else stable) and reused after that. +""" + + +def _no_wrap_array_type(np: Any) -> type: + """The ``_NoWrapArray`` class for this ``np`` module, built on first use. + + Only single-index (or all-integer tuple) access is guarded: a slice, + a boolean mask, or a fancy index is the harness's or the translation's + own choice of view, not a subscript the source computed, and is left + to ndarray's ordinary rules. + """ + cached = _NO_WRAP_ARRAY_TYPES.get(id(np)) + if cached is not None: + return cached + + class _NoWrapArray(np.ndarray): # type: ignore[misc] # ``np`` is a parameter, not the typed module + def __getitem__(self, key: Any) -> Any: + _reject_negative_subscript(key) + return super().__getitem__(key) + + def __setitem__(self, key: Any, value: Any) -> None: + _reject_negative_subscript(key) + super().__setitem__(key, value) + + _NO_WRAP_ARRAY_TYPES[id(np)] = _NoWrapArray + return _NoWrapArray + + INPUT_PROFILE = "recast_inputs.py" """The project's input profile, looked for at the root the run was given. @@ -137,6 +231,79 @@ def _redrawn_note(totals: dict[str, Any]) -> str: """ +def drawable_path(argument: dict[str, Any]) -> bool: + """Whether this dummy is a scratch path the harness may draw. + + A character dummy has no sampling story in general -- an init routine's + ``errstring`` is a message, and a default that drew one would fail the + whole gate on it. One shape does: a dummy the source hands to an OPEN + that *creates* the file (``path: "created"``, from the frontend's + ``opened_files``). Any name works there, because the subprogram makes the + file rather than finding it, so the harness can give each side a scratch + path of its own and compare the two files afterwards. A path the source + opens ``STATUS='OLD'`` is the opposite case: the draw would have to be a + file that already holds something, which is not a value anything here can + produce, and the oracle leaves that subprogram ungated. + """ + return argument.get("dtype") == "str" and argument.get("path") == "created" + + +def _file_bytes(path: Any) -> bytes | None: + """What a side left at a path argument, or ``None`` if it left nothing.""" + try: + return Path(str(path)).read_bytes() + except OSError: + return None + + +class _CallTimedOut(Exception): + """The candidate did not return from a draw within its bound. + + Not an error in the translation: a draw can put a subprogram in a loop + the source itself never leaves -- ``bisect``'s ``do while (b - a > tol)`` + with a negative tolerance halves the interval to zero and keeps going -- + and the reference, being the same algorithm, would not leave it either. + So it is one more way a draw is refused, beside the ERROR STOP and the + out-of-bounds subscript below it, and it is answered the same way: draw + again. + """ + + +@contextlib.contextmanager +def _bounded(seconds: float) -> Iterator[None]: + """Run the block under a wall-clock bound, or unbounded where none can be. + + An interval timer, because the thing to bound is a call inside this + process and the point is to *get back*: a subprocess would need the + candidate module and the same call-back object, and a thread cannot be + stopped. The bound therefore lands where Python next checks for signals, + which is between bytecodes -- a translated loop, which is what runs long + here. It is not a way to interrupt a long call inside a C extension, and + it does not claim to be one. + + Unavailable off the main thread and on platforms without an interval + timer; there the block runs as it always did rather than not at all. + """ + if ( + seconds <= 0 + or not hasattr(signal, "setitimer") + or threading.current_thread() is not threading.main_thread() + ): + yield + return + + def expire(_signum: int, _frame: Any) -> None: + raise _CallTimedOut(f"did not return within {seconds:g}s") + + previous = signal.signal(signal.SIGALRM, expire) + signal.setitimer(signal.ITIMER_REAL, seconds) + try: + yield + finally: + signal.setitimer(signal.ITIMER_REAL, 0) + signal.signal(signal.SIGALRM, previous) + + def _callback_split(interface: dict[str, Any]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: """A call-back interface's arguments, split the way a call to it is made. @@ -144,9 +311,21 @@ def _callback_split(interface: dict[str, Any]) -> tuple[list[dict[str, Any]], li Fortran side supplies are passed in, in declaration order, and the ones it reads back come out of the return, in declaration order. An ``intent(inout)`` argument is in both. + + A function call-back answers through its result, which both sides read off + the return the same way, so the result stands in the outputs as the one + thing the call produces. """ inputs = [a for a in interface["args"] if a["intent"] in ("IN", "INOUT")] outputs = [a for a in interface["args"] if a["intent"] in ("OUT", "INOUT")] + if interface["kind"] == "function": + outputs = [ + { + "name": interface.get("result") or "result", + "dtype": interface.get("result_dtype"), + "intent": "OUT", + } + ] return inputs, outputs @@ -178,8 +357,23 @@ def callback_for(np: Any, name: str, interface: dict[str, Any]) -> Any: f"call-back {name!r} takes argument(s) {', '.join(unsupported)}, which this " "harness cannot supply" ) - if interface["kind"] != "subroutine": - raise ValueError(f"call-back {name!r} is a function; this harness supplies subroutines") + if interface["kind"] == "function": + # The reference reads the result off the return and so does the + # translation, which is the whole convention -- but only when the + # result is the one thing the call produces. A function that also + # writes an argument hands two things back in an order this harness + # would be inventing, and the reference wrapper refuses it too. + if interface.get("result_dtype") not in SUPPORTED_DTYPES: + raise ValueError( + f"call-back {name!r} returns {interface.get('result_dtype')!r}, which this " + "harness cannot supply" + ) + written = [a["name"] for a in interface["args"] if a["intent"] != "IN"] + if written: + raise ValueError( + f"call-back {name!r} is a function that writes argument(s) " + f"{', '.join(written)}; this harness supplies functions that only read theirs" + ) def shape_of(argument: dict[str, Any], bound: dict[str, Any]) -> tuple[int, ...]: axes = [] @@ -265,19 +459,87 @@ def _extent(dim: dict[str, Any], dims: dict[str, int]) -> int: def _resolve_extent(text: str | None, dims: dict[str, int]) -> int: - """A declared dimension's extent under the operator's table.""" + """A declared dimension's extent under the operator's table. + + A name the table does not pin is the default dimension -- which is also + what the harness draws the scalar of that name as (``_generated_inputs``) + -- so ``g(n + 1)`` over an unpinned ``n`` is nine cells beside an ``n`` of + eight, not eight beside eight. Left as a name, the arithmetic failed and + the whole bound fell back to the default, and the reference refused the + array ("0-th dimension must be fixed to 9 but got 8"). + """ + default = int(dims.get("default_dim", DEFAULT_DIMENSION)) if text is None: - return int(dims.get("default_dim", DEFAULT_DIMENSION)) + return default spelled = str(text).strip().lower() if spelled.isdigit(): return int(spelled) resolved = spelled for name, value in dims.items(): resolved = re.sub(rf"\b{re.escape(name.lower())}\b", str(value), resolved) + resolved = re.sub(r"\b[a-z_]\w*\b", str(default), resolved) try: return int(_arithmetic(resolved)) except Exception: - return int(dims.get("default_dim", DEFAULT_DIMENSION)) + return default + + +_SIZE_TERM = re.compile(r"size\(\s*(\w+)\s*,\s*(\d+)\s*\)", re.I) +"""A ``size(name, axis)`` term of a shape guard's extent (axis from zero).""" + + +def _guarded_shapes( + required: list[dict[str, Any]], + guards: Sequence[dict[str, Any]], + dims: dict[str, int], +) -> dict[str, list[int]]: + """The shape to draw each array argument at, under the body's own checks. + + Every extent starts where it always did -- the declared bound under the + operator's table, ``default_dim`` for an assumed-shape one -- and a guard + then says what one of them has to be. ``size(c,1) /= 5`` makes ``c``'s + first extent five; ``size(c,2) /= size(xi)-1`` makes its second one less + than the length of ``xi``, which is a *relation*, so the guards are + applied until they stop changing anything rather than in one pass. + + A guard whose extent does not resolve, or resolves to nothing an array + can have, is left alone: the draw it would make is worse than the default + it replaces, and the subprogram refusing it says so where a shape nobody + can name would not. + """ + shapes = { + str(argument["name"]).lower(): [ + _resolve_extent(dim.get("ub"), dims) for dim in argument["dims"] + ] + for argument in required + if argument.get("dims") + } + for _ in range(len(guards) + 1): + settled = True + for guard in guards: + extent = shapes.get(str(guard.get("arg", "")).lower()) + axis = int(guard.get("axis", 0)) + if extent is None or not 0 <= axis < len(extent): + continue + spelled = _SIZE_TERM.sub( + lambda m: str( + (shapes.get(m.group(1).lower()) or [0])[int(m.group(2))] + if int(m.group(2)) < len(shapes.get(m.group(1).lower()) or []) + else 0 + ), + str(guard.get("extent", "")), + ) + try: + wanted = int(_arithmetic(spelled)) + except Exception: # noqa: S112 - an extent this cannot resolve keeps its default + continue + if wanted < 1 or wanted > MAX_FITTED_EXTENT or wanted == extent[axis]: + continue + extent[axis] = wanted + settled = False + if settled: + break + return shapes # Split by arity rather than kept in one table. A single dict of both is a @@ -343,6 +605,92 @@ def _delegation_chain(name: str, delegated: dict[str, str]) -> str: return f" ({' <- '.join(parts)})" if parts else "" +def flatten_derived( + sub: dict[str, Any], + translated_fn: Any, + plan: dict[str, dict[str, Any]], + translated: Any = None, +) -> tuple[dict[str, Any], Any]: + """The candidate's signature and function with derived-type dummies split + into the flat scalars the reference wrapper takes. + + f2py cannot marshal a derived type, so the oracle spells a dummy of a + type made of scalar components as one dummy per component + (``recast.oracle.f2py.derived_components``) and puts the plan on its + handle: ``{argument: {"type": name, "components": [{"name", "component", + "dtype"}, ...]}}``. This is the same split on the candidate's side. The + returned signature carries the components in the argument's place, with + its intent, so the harness draws, passes and pairs them like any other + scalar; the returned function assembles the object from those draws -- + through the emitted ``_make_()`` factory where the module has one + -- calls the translation, and hands back the object's components where + the translation handed back the object. Nothing about the comparison + changes: every component is a point, an integer one compared exactly. + """ + from types import SimpleNamespace + + from recast.transform.numpy.vocabulary import pysafe + + flat_args: list[dict[str, Any]] = [] + split: list[tuple[str, str, list[dict[str, Any]]]] = [] + for argument in sub["args"]: + entry = plan.get(argument["name"]) + if entry is None or argument.get("optional"): + flat_args.append(argument) + continue + components = list(entry.get("components") or []) + for component in components: + flat_args.append( + { + "name": component["name"], + "dtype": component["dtype"], + "intent": argument["intent"], + "optional": False, + } + ) + split.append((argument["name"], str(entry.get("type", "")), components)) + if not split: + return sub, translated_fn + flat_sub = {**sub, "args": flat_args} + outs = [a for a in sub["args"] if a["intent"] in ("OUT", "INOUT")] + by_name = {name: (type_name, components) for name, type_name, components in split} + + def assemble(type_name: str) -> Any: + factory = getattr(translated, f"_make_{type_name}", None) if translated else None + return factory() if callable(factory) else SimpleNamespace() + + def flat_fn(**kwargs: Any) -> Any: + objects: dict[str, Any] = {} + for name, type_name, components in split: + obj = assemble(type_name) + for component in components: + key = pysafe(component["name"]) + if key in kwargs: + setattr(obj, pysafe(component["component"]), kwargs.pop(key)) + objects[name] = obj + kwargs[pysafe(name)] = obj + result = translated_fn(**kwargs) + if sub["kind"] == "function": + return result + values = ( + list(result) if isinstance(result, tuple) else ([result] if result is not None else []) + ) + if len(values) != len(outs): + return result # the harness reports the count mismatch itself + expanded: list[Any] = [] + for argument, value in zip(outs, values, strict=True): + if argument["name"] in by_name: + _type_name, components = by_name[argument["name"]] + expanded.extend( + getattr(value, pysafe(component["component"])) for component in components + ) + else: + expanded.append(value) + return tuple(expanded) + + return flat_sub, flat_fn + + class BitexactVerifier(Verifier): """Call both sides on the same inputs; count the bits that disagree.""" @@ -355,11 +703,13 @@ class BitexactVerifier(Verifier): A generated draw is not always one the subprogram accepts: an argument outside the domain the source itself declares (``error stop 'invalid mode'``), an extent too small for the subscripts the body forms, a value - that drives the arithmetic into NaN. None of those is a difference - between the two sides -- the reference cannot even be *called* on the - first two without ending the process or reading memory it does not own -- - so the trial is drawn again, with a fresh seed and fresh unpinned - extents, rather than reported as a comparison that failed. + that drives the arithmetic into NaN, a value the source's own loop never + leaves (``call_seconds``). None of those is a difference between the two + sides -- the reference cannot even be *called* on the first two without + ending the process or reading memory it does not own, and would not come + back from the last one either -- so the trial is drawn again, with a + fresh seed and fresh unpinned extents, rather than reported as a + comparison that failed. Bounded, and the bound is the point: a subprogram whose every draw is refused is reported as one that could not be compared, which is what @@ -379,6 +729,13 @@ class BitexactVerifier(Verifier): subprogram that passes mostly that way fails by name, with the number of such trials recorded as ``reshaped``. + A shape refusal is answered before that, and not by a redraw: the extents + nobody pinned are this harness's own choice, so the first one refused is + *grown* until the body's subscripts fit, and the subprogram is compared + again from its first trial at that one shape, which the outcome records + as ``extents`` (:meth:`_fit_extents`). The redraw and the ``reshaped`` + floor are what remains for a body no growth fits. + None of this applies to a draw the project's ``recast_inputs.py`` shaped. That draw is the project saying the source takes it, so there is nothing to draw again: the reference is called first, and if it refuses, the @@ -387,6 +744,16 @@ class BitexactVerifier(Verifier): candidate has failed on inputs the source takes. """ + call_seconds: float = 5.0 + """How long one generated draw may keep the candidate before it is refused. + + Generous by three orders of magnitude: the gate's draws are small -- a + default extent of eight -- and a subprogram that has not answered one in + five seconds is not answering it. What the bound buys is that a draw the + source does not terminate on is a redraw rather than a run that never + ends, and the reference is never called on it. ``0`` turns it off. + """ + dominant_at: float | None = None """Fraction of a row's maximum above which an element is *dominant*. @@ -405,7 +772,17 @@ def verify( executor: Executor, config: dict[str, Any], ) -> Verdict: - verdict = self._compare_all(unit, candidate, oracle, workspace, executor, config) + # Where a subprogram that writes a file writes it. Outside the run's + # workspace on purpose: the reference takes a path through a + # ``character(len=128)`` wrapper dummy, and a workspace path is + # already most of that budget before a file name is added to it. + scratch = Path(tempfile.mkdtemp(prefix="recast-gate-io-")) + try: + verdict = self._compare_all( + unit, candidate, oracle, workspace, executor, config, scratch + ) + finally: + shutil.rmtree(scratch, ignore_errors=True) # An oracle that could not spell every subprogram lists the rest on # its handle; a module that passes with three of its eleven # subprograms compared must say so where the evidence is read. This @@ -418,16 +795,35 @@ def verify( **dict(handle.get("ungated") or {}), **dict(verdict.metrics.get("ungated") or {}), } - if not ungated: + # A procedure the build could not link and recast defined instead, on + # both sides (``recast.references``). Every point a subprogram + # reaching one was compared at was computed with that stand-in, not + # with the library the source names, and a reader of this verdict has + # no other way to know it. + substituted = dict(handle.get("substituted") or {}) + if not ungated and not substituted: return verdict + detail = verdict.detail + if substituted: + detail += ( + f"; {len(substituted)} external(s) stood in for by recast's own " + "reference implementation, on both sides: " + ", ".join(sorted(substituted)) + ) + if ungated: + detail += f"; {len(ungated)} subprogram(s) ungated, no reference: " + ", ".join( + f"{name} ({why})" for name, why in sorted(ungated.items()) + ) return Verdict( unit=verdict.unit, candidate=verdict.candidate, verifier=verdict.verifier, confidence=verdict.confidence, - metrics={**verdict.metrics, "ungated": ungated}, - detail=f"{verdict.detail}; {len(ungated)} subprogram(s) ungated, no reference: " - + ", ".join(f"{name} ({why})" for name, why in sorted(ungated.items())), + metrics={ + **verdict.metrics, + **({"ungated": ungated} if ungated else {}), + **({"substituted": substituted} if substituted else {}), + }, + detail=detail, ) def _compare_all( @@ -438,6 +834,7 @@ def _compare_all( workspace: Path, executor: Executor, config: dict[str, Any], + scratch: Path | None = None, ) -> Verdict: try: import numpy as np @@ -515,16 +912,17 @@ def judged(name: str) -> bool: def not_generable(name: str) -> str | None: """Why this harness cannot produce every required input, or None. - Character arguments have no sampling story yet; a default that - tried would fail the whole gate on an init routine's errstring. - Explicit config still wins -- and then fails loudly. The reason - goes on the verdict by name (numfor's ``print_msg``, a message - to stderr): not compared, and not silent about it. + Character arguments have no sampling story yet, beyond the one + shape ``drawable_path`` names; a default that tried would fail the + whole gate on an init routine's errstring. Explicit config still + wins -- and then fails loudly. The reason goes on the verdict by + name (numfor's ``print_msg``, a message to stderr): not compared, + and not silent about it. """ for a in table[name]["args"]: if a["intent"] == "OUT" or a.get("optional"): continue - if a["dtype"] == "str": + if a["dtype"] == "str" and not drawable_path(a): return f"character argument {a['name']}: no generated draw for one" if a["dtype"] == PROCEDURE_DTYPE and not isinstance(a.get("interface"), dict): # A procedure argument the frontend could not resolve an @@ -561,13 +959,15 @@ def generable(name: str) -> bool: skipped = sorted(set(offered) - set(wanted)) else: by_subprogram = {} + # A subprogram the oracle listed as ungated has no reference to + # compare against -- it says so, and says why, and the reason + # lands on the verdict below. Comparing one anyway compares the + # candidate against a wrapper the oracle has already disclaimed. + disclaimed = set(handle.get("ungated") or {}) | set(config.get("ungated") or {}) wanted = config.get("subprograms") or [ name for name in wrappers - if name in table - and judged(name) - and generable(name) - and name not in declared_ungated + if name in table and judged(name) and generable(name) and name not in disclaimed ] skipped = sorted(set(wrappers) - set(wanted)) # A translated subprogram the harness has no draw for is named @@ -622,6 +1022,8 @@ def generable(name: str) -> bool: # reason, unless declared ungated like any other silence. lowered = getattr(translated, "_JAX_KERNELS", None) delegated = (candidate.notes.get("jax") or {}).get("delegated") or {} + declared_flat = handle.get("flattened") + flattened: dict[str, Any] = declared_flat if isinstance(declared_flat, dict) else {} for name in wanted: sub = table[name] if isinstance(lowered, (list, tuple, set)) and name not in lowered: @@ -630,12 +1032,19 @@ def generable(name: str) -> bool: + _delegation_chain(name, delegated) ) continue - translated_fn = getattr(translated, name, None) + translated_fn = self._candidate_function(translated, name) truth_fn = None if recorded else getattr(truth, wrappers.get(name, f"w_{name}"), None) if translated_fn is None or (truth_fn is None and not recorded): side = "candidate" if translated_fn is None else "oracle" failures.append(f"{name}: missing on the {side} side") continue + if isinstance(flattened.get(name), dict): + # The reference takes this subprogram's derived-type dummies + # component by component; so, for this comparison, does the + # candidate. + sub, translated_fn = flatten_derived( + sub, translated_fn, flattened[name], translated + ) outcome = self._compare_subprogram( np, name, @@ -651,9 +1060,12 @@ def generable(name: str) -> bool: dominant_axis=config.get("dominant_axis", -1), rel_scale=str(config.get("rel_scale", "element")), draws=int(config.get("draws", self.draws_per_trial)), + call_seconds=float(config.get("call_seconds", self.call_seconds)), arg_naming=str(handle.get("arg_naming", "lower")), convention=str(handle.get("return_convention", "f2py")), samples=by_subprogram.get(name) if recorded else None, + scratch=None if scratch is None else scratch / name, + reference_isolated=handle.get("isolation") == "process", ) per_subprogram[name] = outcome if "error" in outcome: @@ -877,10 +1289,22 @@ def _compare_subprogram( dominant_axis: Any = -1, rel_scale: str = "element", draws: int = 1, + call_seconds: float = 0.0, arg_naming: str = "lower", convention: str = "f2py", samples: list[dict[str, Any]] | None = None, + fitted: dict[str, int] | None = None, + scratch: Path | None = None, + reference_isolated: bool = False, ) -> dict[str, Any]: + """Compare one subprogram over ``trials`` draws. + + ``fitted`` is what a first pass grew an unpinned extent to + (:meth:`_fit_extents`): those extents arrive pinned in ``dims``, and + the comparison starts again from the first trial so that every trial + is compared at one shape. It is recorded on the outcome, because the + shape the points were bit-exact at is part of what they say. + """ from recast.transform.numpy.vocabulary import pysafe if convention not in {"f2py", "emitted", "recorded"}: @@ -889,7 +1313,7 @@ def _compare_subprogram( declared_dtypes = [ (f"argument {a.get('name', '')!r}", a.get("dtype")) for a in sub["args"] - if a.get("dtype") != PROCEDURE_DTYPE + if a.get("dtype") != PROCEDURE_DTYPE and not drawable_path(a) ] if sub["kind"] == "function": declared_dtypes.append(("function result", sub.get("result_dtype"))) @@ -910,17 +1334,43 @@ def _compare_subprogram( } required = [a for a in sub["args"] if not a.get("optional")] + # Character dummies the source opens as files it creates. Not values + # to compare -- both sides get a scratch path of their own, and what + # is compared is the file each one left there. None of that applies to + # a replay: its inputs are the recorded run's, including the path it + # actually wrote to, and there is no second side to write a file. + path_arguments = [] if samples is not None else [a for a in required if drawable_path(a)] + if path_arguments and scratch is None: + return { + "error": "argument(s) " + + ", ".join(a["name"] for a in path_arguments) + + " name files the subprogram writes, and this comparison has " + "nowhere to let the two sides write them" + } outs_all = [a for a in sub["args"] if a["intent"] in ("OUT", "INOUT")] outs_required = [a for a in outs_all if not a.get("optional")] - unknown_intents = [a["name"] for a in sub["args"] if a["intent"] == "UNKNOWN"] + # Over the arguments the comparison passes, not every argument the + # subprogram declares. An optional one is dropped from both calls -- + # the wrapper does not take it and the translation spells it as a + # keyword sentinel -- so neither side reads or writes it and its + # intent decides nothing here. ``integer, optional :: maxiter``, which + # is how the corpus's ``secant`` declares its iteration cap, states no + # intent and cost that subprogram its comparison over an argument no + # call made. + unknown_intents = [a["name"] for a in required if a["intent"] == "UNKNOWN"] if unknown_intents: return { "error": "argument(s) " f"{', '.join(unknown_intents)} have UNKNOWN intent; this verifier cannot " "know whether their post-call values are outputs" } - if sub["kind"] == "function" and outs_all: - names = ", ".join(a["name"] for a in outs_all) + if sub["kind"] == "function" and outs_required: + # Required ones only, for the reason the comment above gives: an + # optional dummy is dropped from both calls, so a function with + # an optional intent(out) argument -- ``newunit(unit)``, whose + # argument exists for callers that want the number twice -- has + # no side effect to pair with its result on the call being made. + names = ", ".join(a["name"] for a in outs_required) return { "error": f"function {name!r} declares OUT/INOUT dummy argument(s) " f"{names}; this verifier cannot pair both its result and side effects" @@ -951,6 +1401,25 @@ def _compare_subprogram( r"[a-z_]\w*", f"{dim.get('lb') or ''} {dim.get('ub') or ''}".lower() ) } + # What the body's own entry checks say its dummies' shapes must be. + # An assumed-shape dummy declares neither extent, and a subprogram + # that stops unless ``size(c,1)`` is five has said the one thing this + # harness could otherwise only guess -- and guess wrongly on every + # draw it makes. + guards = list(sub.get("shape_guards") or []) + # ... and what they say about their values. ``bctype`` is a plain + # integer dummy that the body stops on unless it is 1 or 2, so a draw + # from this harness's default integer range is refused fifteen times + # in sixteen and the subprogram runs out of attempts having compared + # nothing. The operator's own range still wins: a project that has + # said what it wants drawn has said it about this argument too. + ranges = { + **{ + str(guard["arg"]).lower(): (float(guard["low"]), float(guard["high"])) + for guard in sub.get("value_guards") or [] + }, + **ranges, + } points = bit_exact = nan_mismatch = 0 integer_points = integer_mismatch = 0 @@ -971,7 +1440,15 @@ def _compare_subprogram( # *shape*: a packed triangular workspace wants an extent that is a # function of the order it goes with, and one drawn independently of # that order is a subscript past the end rather than a comparison. + # An extent this harness already grew is pinned, and is in ``dims`` + # rather than here. free_extents = sorted(dimension_names - {str(k).lower() for k in dims}) + # Whether a shape refusal is still answered by growing the extents. + # Off once a growth has been fitted, once a search has found none -- + # a second search over the same shapes would find the same nothing -- + # and for a replay or a profiled draw, whose extents are not this + # harness's to choose. + fitting = samples is None and profile is None and not fitted attempts = 1 if samples is not None else max(1, int(draws)) # Replayed samples are the trials, and there are as many as were # recorded. ``trials`` is a sampling parameter and does not apply: a @@ -1004,43 +1481,42 @@ def _compare_subprogram( for extent in free_extents: trial_dims[extent] = int(rng.integers(1, ceiling + 1)) staged: list[dict[str, Any]] = [] + # A path argument is drawn as a scratch name, one per side: + # both sides create the file the source's OPEN creates, and + # writing to one path would have the second call overwrite + # what the comparison is about to read. + drawn_paths: dict[str, str] = {} + truth_paths: dict[str, str] = {} + if path_arguments: + trial_root = Path(str(scratch)) / f"{round_index}.{attempt}" + for side in ("c", "r"): + (trial_root / side).mkdir(parents=True, exist_ok=True) + drawn_paths = { + a["name"]: str(trial_root / "c" / a["name"]) for a in path_arguments + } + truth_paths = { + a["name"]: str(trial_root / "r" / a["name"]) for a in path_arguments + } if samples is not None: bound = self._recorded_inputs(np, required, round_item) if isinstance(bound, str): return {"error": bound} inputs = bound else: - inputs = {} - for argument in required: - if argument["intent"] == "OUT" and not argument.get("buffer"): - continue - # An intent(out) buffer is the caller's storage: generated - # like an input, handed to the candidate, and compared - # after the call the way any output is. - lowered = argument["name"].lower() - if argument.get("dtype") == PROCEDURE_DTYPE: - interface = argument.get("interface") - if not isinstance(interface, dict): - return { - "error": f"procedure argument {argument['name']!r} carries no " - "interface; there is nothing to build a call-back from" - } - try: - inputs[argument["name"]] = callback_for( - np, argument["name"], interface - ) - except ValueError as error: - return {"error": str(error)} - elif not argument.get("dims") and ( - lowered in dimension_names or lowered in dims - ): - inputs[argument["name"]] = np.int32( - _resolve_extent(lowered, trial_dims) - ) - else: - inputs[argument["name"]] = self._value( - np, argument, trial_dims, ranges, rng - ) + drawn = self._generated_inputs( + np, + required, + dimension_names, + dims, + trial_dims, + ranges, + rng, + drawn_paths, + guards, + ) + if isinstance(drawn, str): + return {"error": drawn} + inputs = drawn recorded_outputs = None if samples is not None: @@ -1094,12 +1570,21 @@ def _compare_subprogram( # anchor emitted by this engine's own backend spells names the # emitted way instead, because both sides of that comparison came # out of the same emitter. + # + # A caller-buffer OUT array is handed to the reference as well: + # it is the caller's storage on both sides, and the reference + # cannot allocate what its wrapper never sized. The copy + # ``_truth_input`` makes keeps the two sides independent. spell = pysafe if arg_naming == "pysafe" else _f2py_name + handed = [ + a + for a in required + if a["intent"] != "OUT" or (a.get("buffer") and a["name"] in inputs) + ] try: truth_kwargs = { spell(a["name"]): self._truth_input(np, a, inputs[a["name"]], convention) - for a in required - if a["intent"] != "OUT" or _passed_buffer(a) + for a in handed } except Exception as error: if shaped: @@ -1110,11 +1595,9 @@ def _compare_subprogram( return { "error": f"oracle input preparation failed: {type(error).__name__}: {error}" } - truth_args = [ - truth_kwargs[spell(a["name"])] - for a in required - if a["intent"] != "OUT" or _passed_buffer(a) - ] + for argument_name, reference_path in truth_paths.items(): + truth_kwargs[spell(argument_name)] = reference_path + truth_args = [truth_kwargs[spell(a["name"])] for a in handed] if shaped: # The profile asserts the reference takes this draw, so the # reference goes first and decides. Refused there, the @@ -1138,8 +1621,46 @@ def _compare_subprogram( } else: try: - translated_out = translated_fn(**translated_kwargs) + with _bounded(call_seconds): + translated_out = translated_fn(**translated_kwargs) + except _CallTimedOut as error: + # The draw, not the translation: the reference runs + # the same loop and would not come back from it + # either, so it is not called on this one. + declined = f"candidate {error}" + redrawn += 1 + continue except (SystemExit, IndexError) as error: + if ( + isinstance(error, _NegativeSubscript) + and reference_isolated + and truth_fn is not None + and samples is None + ): + # The candidate refused a subscript below the + # dummy's declared lower bound rather than wrapping + # it to the other end (see ``_NoWrapArray``). A + # bounds-checked reference in its own process is the + # authority on such a draw: it names the array and + # the bound and ends cleanly, so the draw is + # declined under the reference's own reason (#42). + # An in-process reference cannot be trusted to + # survive the read, so it is left uncalled and the + # candidate's refusal stands. + try: + truth_fn(**truth_kwargs) + except ReferenceAborted as ref_error: + declined = f"reference aborted: {ref_error}" + why = ( + "reference subscript out of bounds" + if _bounds_violation(ref_error.runtime_error) + else "reference error stop" + ) + declined_by[why] = declined_by.get(why, 0) + 1 + redrawn += 1 + continue + except Exception: # noqa: S110 - did not abort: the candidate's refusal stands + pass # Not a comparison that failed -- a draw the subprogram # does not take. ``SystemExit`` is a translated ERROR # STOP: the source itself saying these arguments are not @@ -1151,10 +1672,77 @@ def _compare_subprogram( # not own. Either way the reference must not be called # on this draw; draw again. declined = f"candidate raised: {type(error).__name__}: {error}" - reshape = reshape or isinstance(error, IndexError) overrun = isinstance(error, IndexError) why = "subscript past extent" if overrun else "error stop" declined_by[why] = declined_by.get(why, 0) + 1 + # A ``_NegativeSubscript`` is a value outside the + # source's own domain, not a shape this harness's + # default got wrong (see the class) -- no extent + # grows its way out of a negative index, so it is + # not a candidate for ``_fit_extents`` and does not + # earn the subprogram a ``reshaped`` count below. + growable = isinstance(error, IndexError) and not isinstance( + error, _NegativeSubscript + ) + if growable and fitting and free_extents: + # A subscript past the end at extents nobody + # pinned is this harness's own default being + # wrong about the shape, not the draw being + # wrong about the values. Grow the default until + # the body's subscripts fit and compare the + # subprogram again from its first trial, so that + # every trial is compared at one shape. Only + # once: what the growth finds is pinned, and what + # it does not find is what the redraw below is + # for. A project that shapes its own inputs has + # said what its subprograms take, and a replay's + # extents are the recording's. + table = self._fit_extents( + np, + name, + required, + dimension_names, + translated_fn, + dims, + ranges, + free_extents, + trials, + call_seconds, + path_arguments, + scratch, + guards, + ) + grown = {e: int(table[e]) for e in free_extents if e in table} + if grown: + return self._compare_subprogram( + np, + name, + sub, + translated_fn, + truth_fn, + trials, + table, + ranges, + profile=profile, + unit_uid=unit_uid, + dominant_at=dominant_at, + dominant_axis=dominant_axis, + rel_scale=rel_scale, + draws=draws, + call_seconds=call_seconds, + arg_naming=arg_naming, + convention=convention, + samples=samples, + fitted=grown, + scratch=scratch, + reference_isolated=reference_isolated, + ) + fitting = False + # Only where there is an extent left to move: with + # every extent pinned or fitted there is nothing to + # reshape, and counting the trial as reshaped would + # name extents that did not move. + reshape = reshape or (bool(free_extents) and growable) redrawn += 1 continue except Exception as error: @@ -1207,6 +1795,15 @@ def _compare_subprogram( if sub["kind"] == "function" else {a["name"]: a.get("dtype") for a in outs_all} ) + if path_arguments: + produced = self._file_outputs(np, path_arguments, drawn_paths, truth_paths) + if isinstance(produced, str): + return {"error": produced} + pairs = [*pairs, *produced] + output_dtypes = { + **output_dtypes, + **{label: "int32" for label, _ours, _theirs in produced}, + } for label, ours, theirs in pairs: declared_dtype = output_dtypes.get(label) if declared_dtype in {"int32", "int64"}: @@ -1394,6 +1991,11 @@ def _compare_subprogram( "reshaped": reshaped, "shaped": shaped_trials, } + if fitted: + # What was compared, at extents this harness chose: a reader who + # is told the points were bit-exact is owed the shape they were + # bit-exact at. + outcome["extents"] = fitted if dominant_at is not None: outcome["max_ulp_dominant"] = max_ulp_dominant outcome["dominant_points"] = dominant_points @@ -1401,6 +2003,117 @@ def _compare_subprogram( outcome["per_sample"] = per_sample return outcome + def _fit_extents( + self, + np: Any, + name: str, + required: list[dict[str, Any]], + dimension_names: set[str], + translated_fn: Any, + dims: dict[str, int], + ranges: dict[str, tuple[float, float]], + free_extents: list[str], + trials: int, + call_seconds: float, + path_arguments: list[dict[str, Any]] | None = None, + scratch: Path | None = None, + guards: Sequence[dict[str, Any]] = (), + ) -> dict[str, int]: + """Grow the extents nobody pinned until the body's subscripts fit. + + An extent no operator pinned is this harness's own choice rather than + a value the run asked for: every one of them is ``default_dim``. For a + packed workspace that choice is never right and cannot be -- MINPACK's + ``dogleg`` reads the upper triangle of an order-``n`` matrix out of + ``r(lr)``, so ``lr`` has to be ``n(n+1)/2`` and is never ``n`` -- and + at it the subprogram is not comparable at all: the first subscript the + body forms is already past the end. + + So the default is *grown*, and only ever grown. A longer workspace at + the same order is the problem the operator configured, one size larger; + the extents a shape redraw moves to are a *smaller* problem, and a + different one every trial, which is why a subprogram compared that way + fails by name (see the ``reshaped`` floor). The growth is decided once, + at the first shape a trial refused, and the subprogram is compared + again from its first trial at it -- so every trial holds one shape, + and the metrics say which. + + The candidate's own refusal is what a shape is judged by: an + ``IndexError`` is a subscript past a dummy's declared extent and there + is nothing else here that knows what the body needs. The reference is + not called -- on these draws it would read memory the call does not + own -- and a draw refused for its *values* (an ``ERROR STOP``, a loop + it does not come back from) says nothing about the shape, so it is + neither a fit nor a reason to grow. + """ + from recast.transform.numpy.vocabulary import pysafe + + def fits(table: dict[str, int]) -> bool: + for index in range(trials): + # The trials' own draws, at the shape under test: a shape + # fitted against draws of this pass's own would be a shape + # nothing that gets compared was ever made at. + rng = np.random.default_rng( + int.from_bytes(f"{name}:{index}".encode(), "big") % 2**32 + ) + paths = {} + if path_arguments and scratch is not None: + root = Path(str(scratch)) / f"fit.{index}" + root.mkdir(parents=True, exist_ok=True) + paths = {a["name"]: str(root / a["name"]) for a in path_arguments} + inputs = self._generated_inputs( + np, required, dimension_names, dims, table, ranges, rng, paths, guards + ) + if isinstance(inputs, str): + return True # no draw to make: not a shape this can fit + kwargs = { + pysafe(a["name"]): inputs[a["name"]] + for a in required + if a["intent"] != "OUT" or (a.get("buffer") and a["name"] in inputs) + } + try: + with _bounded(call_seconds): + translated_fn(**kwargs) + except _NegativeSubscript: + continue # a value refusal, not a shape one -- see the class + except IndexError: + return False + except (Exception, SystemExit): # noqa: S112 - a value refusal, not a shape + continue # this draw has nothing to say about the shape + return True + + if fits(dims): + return dims + base = {extent: _resolve_extent(extent, dims) for extent in free_extents} + # Each extent alone, and the one sizing fewest of the subprogram's + # arrays first: a packed workspace is the extent of one array, where + # an order is what every other array is cut to. Growing the order + # raises the requirement along with the supply and arrives nowhere, + # and it is the order the operator's own default is a statement + # about. Every extent together is tried last, for a body whose + # workspaces are more than one. + sized = { + extent: sum( + 1 + for argument in required + for dim in argument.get("dims") or [] + if extent in re.findall(r"[a-z_]\w*", str(dim.get("ub") or "").lower()) + ) + for extent in free_extents + } + groups = [[extent] for extent in sorted(free_extents, key=lambda e: (sized[e], e))] + if len(free_extents) > 1: + groups.append(sorted(free_extents)) + for group in groups: + for factor in GROWTH_FACTORS: + grown = {e: min(base[e] * factor, MAX_FITTED_EXTENT) for e in group} + if all(grown[e] == base[e] for e in group): + continue + table = {**dims, **grown} + if fits(table): + return table + return dims + @staticmethod def _devices(translated: Any, handle: dict[str, Any]) -> dict[str, str]: """Which device each side ran on, when either side says. @@ -1622,6 +2335,44 @@ def _integer_output( ) return raw.astype(target, copy=False) + @staticmethod + def _file_outputs( + np: Any, + path_arguments: list[dict[str, Any]], + drawn_paths: dict[str, str], + truth_paths: dict[str, str], + ) -> list[tuple[str, Any, Any]] | str: + """The file each side left at a path argument, as an output to compare. + + A subprogram whose only product is a file has no output argument for + ``_paired_outputs`` to pair -- ``saveppm(filename, img)`` declares two + inputs and nothing else -- and comparing its arguments compares what + the caller already knew. What it produced is the bytes it wrote, and + those are compared as declared integers: the bit-exact bar for a file + is that it holds the same bytes, in the same order, and one that is a + byte longer is a different file rather than a near-miss. + + A side that wrote nothing is not an empty file to compare against an + empty file: the source's OPEN creates one, so its absence is the call + having done nothing, and it is named rather than passed over. + """ + produced: list[tuple[str, Any, Any]] = [] + for argument in path_arguments: + label = f"{argument['name']} (file)" + ours = _file_bytes(drawn_paths[argument["name"]]) + theirs = _file_bytes(truth_paths[argument["name"]]) + if ours is None or theirs is None: + side = "candidate" if ours is None else "oracle" + return f"{label}: the {side} left no file at the path it was given" + produced.append( + ( + label, + np.frombuffer(ours, dtype=np.uint8).astype(np.int32), + np.frombuffer(theirs, dtype=np.uint8).astype(np.int32), + ) + ) + return produced + @staticmethod def _paired_outputs( sub: dict[str, Any], @@ -1722,27 +2473,83 @@ def _paired_outputs( if isinstance(truth_out, tuple) else ([truth_out] if truth_out is not None else []) ) - # A caller-buffer OUT array of no declared extent went in and was - # written in place (the wrapper spells it ``inout``): read back - # from what was passed, like an INOUT, not from the return. - pure_out = [a for a in outs_required if a["intent"] == "OUT" and not _passed_buffer(a)] + # A caller-buffer OUT array is not among them: the wrapper spells it + # ``intent(in out)``, because the caller owns the storage on both + # sides, so it is read back from the array that was passed exactly as + # an INOUT is. + pure_out = [a for a in outs_required if a["intent"] == "OUT" and not a.get("buffer")] if len(theirs_out) != len(pure_out): return ( f"oracle returned {len(theirs_out)} value(s) for " f"{len(pure_out)} intent(out) argument(s)" ) theirs = dict(zip([a["name"] for a in pure_out], theirs_out, strict=True)) - passed_in = [a["name"] for a in required if a["intent"] != "OUT" or _passed_buffer(a)] - for argument in outs_required: - if argument["intent"] == "INOUT" or _passed_buffer(argument): - theirs[argument["name"]] = truth_args[passed_in.index(argument["name"])] - if argument["intent"] == "INOUT" and argument.get("dtype") == "bool": - if not argument.get("dims") and convention == "f2py": - # Back from the wrapper's integer to the logical. - theirs[argument["name"]] = bool(int(theirs[argument["name"]]) != 0) + passed_in = [a["name"] for a in required if a["intent"] != "OUT" or a.get("buffer")] + read_back = [a for a in outs_required if a["intent"] == "INOUT" or a.get("buffer")] + if read_back and len(truth_args) != len(passed_in): + return ( + f"the reference was handed {len(truth_args)} argument(s) for " + f"{len(passed_in)} the gate has to read an updated value back from" + ) + for argument in read_back: + theirs[argument["name"]] = truth_args[passed_in.index(argument["name"])] + if argument["intent"] == "INOUT" and argument.get("dtype") == "bool": + if not argument.get("dims") and convention == "f2py": + # Back from the wrapper's integer to the logical. + theirs[argument["name"]] = bool(int(theirs[argument["name"]]) != 0) return [(a["name"], by_name[a["name"]], theirs[a["name"]]) for a in outs_required] + def _generated_inputs( + self, + np: Any, + required: list[dict[str, Any]], + dimension_names: set[str], + dims: dict[str, int], + trial_dims: dict[str, int], + ranges: dict[str, tuple[float, float]], + rng: Any, + paths: dict[str, str] | None = None, + guards: Sequence[dict[str, Any]] = (), + ) -> dict[str, Any] | str: + """One draw's inputs by argument name, or why there is no draw. + + ``dims`` is what the operator pinned and ``trial_dims`` what this draw + is being made at; they differ where an extent nobody pinned has been + moved or grown for this trial. ``paths`` is the scratch name each + path argument is drawn as, chosen by the caller because the two sides + need different ones. + """ + inputs: dict[str, Any] = {} + shapes = _guarded_shapes(required, guards, trial_dims) + for argument in required: + if argument["intent"] == "OUT" and not argument.get("buffer"): + continue + # An intent(out) buffer is the caller's storage: generated + # like an input, handed to the candidate, and compared + # after the call the way any output is. + lowered = argument["name"].lower() + if argument["name"] in (paths or {}): + inputs[argument["name"]] = (paths or {})[argument["name"]] + elif argument.get("dtype") == PROCEDURE_DTYPE: + interface = argument.get("interface") + if not isinstance(interface, dict): + return ( + f"procedure argument {argument['name']!r} carries no " + "interface; there is nothing to build a call-back from" + ) + try: + inputs[argument["name"]] = callback_for(np, argument["name"], interface) + except ValueError as error: + return str(error) + elif not argument.get("dims") and (lowered in dimension_names or lowered in dims): + inputs[argument["name"]] = np.int32(_resolve_extent(lowered, trial_dims)) + else: + inputs[argument["name"]] = self._value( + np, argument, trial_dims, ranges, rng, shapes.get(lowered) + ) + return inputs + def _value( self, np: Any, @@ -1750,6 +2557,7 @@ def _value( dims: dict[str, int], ranges: dict[str, tuple[float, float]], rng: Any, + extents: list[int] | None = None, ) -> Any: name = argument["name"].lower() kinds = { @@ -1768,7 +2576,11 @@ def _value( dtype = kinds[argument["dtype"]] shape = None if argument.get("dims"): - shape = tuple(_extent(d, dims) for d in argument["dims"]) + shape = ( + tuple(extents) + if extents is not None + else tuple(_extent(d, dims) for d in argument["dims"]) + ) if dtype in (np.complex128, np.complex64): low, high = ranges.get(name, DEFAULT_RANGE) part = np.float32 if dtype is np.complex64 else np.float64 @@ -1781,7 +2593,8 @@ def _value( low, high = ranges.get(name, DEFAULT_RANGE) if shape is None: return dtype(rng.uniform(low, high)) - return np.asfortranarray(rng.uniform(low, high, size=shape).astype(dtype)) + drawn = np.asfortranarray(rng.uniform(low, high, size=shape).astype(dtype)) + return drawn.view(_no_wrap_array_type(np)) if dtype in (np.int32, np.int64): # The source's own domain for the dummy (``select case (mode)`` # with a stopping default) bounds the draw; the operator's @@ -1790,9 +2603,10 @@ def _value( low, high = ranges.get(name, fallback) if shape is None: return dtype(rng.integers(int(low), int(high) + 1)) - return np.asfortranarray( + drawn = np.asfortranarray( rng.integers(int(low), int(high) + 1, size=shape).astype(dtype) ) + return drawn.view(_no_wrap_array_type(np)) # A logical takes a range like anything else: ``pivot`` decides # whether ``qrfac`` writes ``ipvt`` at all, and an operator with no # way to pin it is comparing an array one side never defined. @@ -1800,7 +2614,8 @@ def _value( low, high = min(int(low), int(high)), max(int(low), int(high)) if shape is None: return np.bool_(rng.integers(low, high + 1)) - return np.asfortranarray(rng.integers(low, high + 1, size=shape).astype(np.bool_)) + drawn = np.asfortranarray(rng.integers(low, high + 1, size=shape).astype(np.bool_)) + return drawn.view(_no_wrap_array_type(np)) # -- loading -------------------------------------------------------------- @@ -1894,6 +2709,23 @@ def _shape_inputs( ) return shaped, True + @staticmethod + def _candidate_function(translated: Any, name: str) -> Any: + """The emitted translation of the subprogram ``_SIGNATURES`` names. + + The table is in the source's vocabulary, and a Fortran name that is a + Python keyword cannot be a Python definition of the same spelling: + ``subroutine assert`` is emitted ``def assert_``. The trailing + underscore is PEP 8's convention and a fact about Python rather than + about any one backend -- ``static.rwset`` strips it back by the same + rule -- so it is read back here, and a subprogram whose name needed + it stops being invisible to this gate. + """ + found = getattr(translated, name, None) + if found is None and keyword.iskeyword(name): + found = getattr(translated, f"{name}_", None) + return found + @staticmethod def _load_candidate( candidate: Candidate, @@ -1916,25 +2748,39 @@ def _load_candidate( as the candidate, and the recipe says which by setting ``config["module_suffix"]``. Defaulting to the NumPy module keeps every existing config meaning what it did. + + Among the files that carry the suffix, the one under judgement is the + unit's own -- ``_numpy.py`` for ``fortran:``. It used to + be whichever came last in ``Candidate.files``, which was this unit's + for as long as a candidate held exactly one such file. It no longer + does: a candidate carries the translations of the siblings it + imports, and a bundle written and read back is ordered by path, so + ``sorting`` was judged against ``utils_numpy.py`` -- its signature + table, its coverage, none of them the unit's. Which module a gate + judges is not the file order's to decide. """ staged = workspace / "candidate" staged.mkdir(parents=True, exist_ok=True) - module_path = None + staged_stems = set() + offered: list[Path] = [] for path, content in candidate.files.items(): target = staged / path target.parent.mkdir(parents=True, exist_ok=True) target.write_bytes(content) + staged_stems.add(target.stem) if str(path).endswith(suffix): - module_path = target - if module_path is None: - raise FileNotFoundError(f"candidate carries no *{suffix} module") + offered.append(target) + module_path = _module_under_judgement(candidate.unit, offered, suffix) entries = [str(staged), *(str(p) for p in companions if str(p) != str(staged))] for entry in reversed(entries): sys.path.insert(0, entry) try: + # Every staged name, not just the module under judgement: a + # sibling left in ``sys.modules`` by an earlier unit would be + # imported instead of the copy this candidate carries. for name in list(sys.modules): - if name == module_path.stem or name.endswith("_constants"): + if name in staged_stems or name.endswith("_constants"): del sys.modules[name] spec = importlib.util.spec_from_file_location(module_path.stem, module_path) assert spec is not None and spec.loader is not None @@ -1959,6 +2805,33 @@ def _verdict( ) +def _module_under_judgement(unit: str, offered: list[Path], suffix: str) -> Path: + """Which of a candidate's suffix-carrying files is the unit's own. + + One file is the unit's translation; the rest are the siblings it imports. + Picking by name rather than by position keeps the answer the same whether + the candidate came straight from the transform or through a bundle, which + orders files by path. Two files that both claim the name, or none that + does, is a candidate this gate cannot judge -- and a verifier says so + rather than guessing. + """ + if not offered: + raise FileNotFoundError(f"candidate carries no *{suffix} module") + if len(offered) == 1: + return offered[0] + own = f"{unit.rpartition(':')[2].rpartition('/')[2].lower()}{suffix}" + named = [path for path in offered if path.name.lower() == own] + if len(named) == 1: + return named[0] + carried = ", ".join(sorted(path.name for path in offered)) + raise FileNotFoundError( + f"candidate for {unit} carries {len(offered)} *{suffix} modules ({carried}); " + + ("none of them is" if not named else f"{len(named)} of them are") + + f" the unit's own {own}, and which one is under judgement cannot be " + "guessed from the file order" + ) + + def _profile_site(unit_uid: str, name: str, trial: int) -> str: return f"{INPUT_PROFILE}: prepare({unit_uid!r}, {name!r}) at trial {trial}" diff --git a/src/recast/verify/rwset.py b/src/recast/verify/rwset.py index 4dba159..f810263 100644 --- a/src/recast/verify/rwset.py +++ b/src/recast/verify/rwset.py @@ -52,10 +52,12 @@ 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+|_|_g") +DISCARD = re.compile(r"_wm\d*|_wn\d*|_we\d+_\d+|_do(?:lo|hi|st)_\w+|_|_g") """Scaffolding targets: a discarded value, the where-construct's masks (the branch mask ``_wm``, what no branch has claimed ``_wn``, a masked -elsewhere's own ``_we_``), a region label.""" +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.""" PRESENT_SENTINEL = re.compile(r"want_(\w+)") """``want_x`` is how an optional output argument is spelled on the target side; @@ -152,13 +154,24 @@ def from_notes(cls, notes: dict[str, Any]) -> Protocol | None: class _Visitor(ast.NodeVisitor): """Read and write sets of emitted Python, in source-side vocabulary.""" - def __init__(self, protocol: Protocol, own: str = "") -> None: + def __init__( + self, protocol: Protocol, own: str = "", bound: frozenset[str] = frozenset() + ) -> None: self.reads: set[str] = set() self.writes: set[str] = set() self.protocol = protocol self.own = own """The subprogram this block belongs to: a load of this name is its result variable, not a call.""" + self.bound = bound + """Names the enclosing emitted function binds itself -- its parameters + and every assignment target in it. Python resolves such a name to the + local wherever it appears in the function, whatever a module-level + ``def`` of the same name says, and so does Fortran: ``rpqr79``'s local + ``scale`` is data even though ``cpoly`` contains a function ``scale``. + A procedure name in this set is therefore a variable where it is + loaded as a value; at callee position it stays a call, which keeps + the result-variable convention and recursion as they were.""" # -- name mapping --------------------------------------------------------- @@ -179,7 +192,12 @@ def back(self, name: str, *, store: bool) -> str | None: # A *store* to a procedure name is the Fortran result-variable # convention (`function f(...)` assigning to `f`), not a call; so is # a *load* of the block's own name. - if name in self.protocol.procedures and not store and name != self.own: + if ( + name in self.protocol.procedures + and not store + and name != self.own + and name not in self.bound + ): return None if HOISTED_LITERAL.fullmatch(name): return None @@ -203,10 +221,16 @@ def visit_Call(self, node: ast.Call) -> None: if not (isinstance(callee, ast.Name) and callee.id in self.protocol.procedures): self.visit(callee) arguments = list(node.args) - if isinstance(callee, ast.Name) and callee.id == "_f_copy_out" and arguments: - # ``_f_copy_out(dst, src)`` writes into ``dst``. The AST has it in - # Load context, so visiting it would record a read, and the - # source side marks the intent(OUT) actual a write (#20). + if ( + isinstance(callee, ast.Name) + and callee.id in ("_f_copy_out", "_f_seq_tail_out") + and arguments + ): + # ``_f_copy_out(dst, src)`` writes into ``dst``, and so does + # ``_f_seq_tail_out(dst, start, src)``, the write-back of an + # assumed-size dummy's storage. The AST has ``dst`` in Load + # context, so visiting it would record a read, and the source + # side marks the intent(OUT) actual a write (#20). self._store(arguments.pop(0)) for argument in arguments: self.visit(argument) @@ -352,13 +376,29 @@ def walk_stmt(node: ast.stmt) -> None: for top in tree.body: if isinstance(top, (ast.FunctionDef, ast.AsyncFunctionDef)): + # The function's own bindings, computed only for the one the span + # falls in: a file of hundreds of blocks is walked once per block. + end = getattr(top, "end_lineno", None) + overlaps = end is None or (top.lineno <= hi and lo <= end) + visitor.bound = bound_names(top) if overlaps else frozenset() for stmt in top.body: walk_stmt(stmt) else: + visitor.bound = frozenset() walk_stmt(top) return visitor.reads, visitor.writes +def bound_names(function: ast.FunctionDef | ast.AsyncFunctionDef) -> frozenset[str]: + """Every name an emitted function binds: its parameters and its assignment, + loop and comprehension targets. What Python treats as local to it.""" + names = {a.arg for a in function.args.args + function.args.kwonlyargs} + for node in ast.walk(function): + if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store): + names.add(node.id) + return frozenset(names) + + STUB_LINE = re.compile(r"^\s*(?:[^#]*#.*\(infra stub\)|#.*)$") """A line the transform emitted for a framework stub, whatever the stub's statement is: a ``pass`` for a call that does nothing here, a ``raise`` for diff --git a/tests/test_bitexact_derived.py b/tests/test_bitexact_derived.py new file mode 100644 index 0000000..23e4523 --- /dev/null +++ b/tests/test_bitexact_derived.py @@ -0,0 +1,147 @@ +"""Tests for how ``differential.bitexact`` compares a subprogram whose +derived-type dummy the reference wrapper spells component by component. + +f2py cannot marshal a derived type. The oracle takes one of scalar +components as a flat dummy per component and puts the plan on its handle +(``recast.oracle.f2py.flattened_dummies``); the verifier splits the +candidate's argument the same way, so every component is a drawn input and +a compared output, and nothing about the comparison itself changes. +""" + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import pytest + +pytest.importorskip("numpy", reason="needs recast-engine[translate]") + +import numpy as np + +from recast.executors.local import LocalExecutor +from recast.model import Candidate, Confidence, OracleRef, Unit +from recast.verify.bitexact import BitexactVerifier, flatten_derived + +PLAN = { + "state": { + "type": "state_t", + "components": [ + {"name": "state_a", "component": "a", "dtype": "float64"}, + {"name": "state_i", "component": "i", "dtype": "int32"}, + ], + } +} + +SIGNATURE = { + "kind": "subroutine", + "public": True, + "args": [ + {"name": "n", "intent": "IN", "dtype": "int32", "optional": False}, + { + "name": "x", + "intent": "INOUT", + "dtype": "float64", + "optional": False, + "dims": [{"lb": "1", "ub": "n"}], + }, + {"name": "state", "intent": "INOUT", "dtype": "UNKNOWN(TYPE(STATE_T))", "optional": False}, + {"name": "y", "intent": "OUT", "dtype": "float64", "optional": False}, + ], +} + +MODULE = """\ +import numpy as np + +_SIGNATURES = {"step": SIGNATURE_LITERAL} + + +class _new_derived: + pass + + +def _make_state_t(): + o = _new_derived() + o.a = 0.0 + o.i = 0 + return o + + +def step(n, x, state, y=None): + x[...] = x * 2.0 + state.a = state.a + float(np.sum(x)) + state.i = state.i + int(n) + y = state.a * 0.5 + return x, state, y +""".replace("SIGNATURE_LITERAL", repr(SIGNATURE)) + + +def test_flatten_derived_splits_the_signature_and_the_returns() -> None: + """The flat signature carries the components in the argument's place + with its intent; the flat function assembles the object from those + draws and hands its components back where the object was returned.""" + seen: dict[str, Any] = {} + + def step(n: Any, x: Any, state: Any) -> Any: + seen["state"] = state + state.a = state.a + 1.0 + state.i = state.i * 2 + return x, state, 7.0 + + flat_sub, flat_fn = flatten_derived(SIGNATURE, step, PLAN) + assert [a["name"] for a in flat_sub["args"]] == ["n", "x", "state_a", "state_i", "y"] + assert [a["intent"] for a in flat_sub["args"]][2:4] == ["INOUT", "INOUT"] + assert flat_sub["args"][3]["dtype"] == "int32" + out = flat_fn(n=np.int32(2), x=np.zeros(2), state_a=np.float64(0.5), state_i=np.int32(3)) + assert isinstance(seen["state"], SimpleNamespace) + assert out[1] == 1.5 and out[2] == 6 and out[3] == 7.0 and len(out) == 4 + + +def test_flatten_derived_leaves_a_signature_without_derived_dummies_alone() -> None: + plain = {**SIGNATURE, "args": [a for a in SIGNATURE["args"] if a["name"] != "state"]} + + def step(**kwargs: Any) -> Any: + return None + + assert flatten_derived(plain, step, PLAN) == (plain, step) + + +def test_a_flattened_derived_type_compares_bit_exact_end_to_end(tmp_path: Path) -> None: + """The reference takes the flat scalars f2py's way -- INOUT rank-0 + buffers updated in place, the OUT returned -- and the candidate takes + the object its translation was emitted with; the gate compares every + component as a point of its own.""" + + def w_step(n: Any, x: Any, state_a: Any, state_i: Any) -> Any: + x[...] = x * 2.0 + state_a[...] = state_a + float(np.sum(x)) + state_i[...] = state_i + int(n) + return float(state_a) * 0.5 + + candidate = Candidate( + unit="fortran:m", transform="test.derived", files={Path("m_numpy.py"): MODULE.encode()} + ) + oracle = OracleRef( + unit="fortran:m", + oracle="test.python-truth", + key="k", + handle={ + "module": SimpleNamespace(w_step=w_step), + "wrappers": {"step": "w_step"}, + "flattened": {"step": PLAN}, + }, + ) + verdict = BitexactVerifier().verify( + Unit(uid="fortran:m", kind="module"), + candidate, + oracle, + tmp_path, + LocalExecutor(), + {"trials": 4}, + ) + assert verdict.confidence is Confidence.BIT_EXACT, verdict.detail + outcome = verdict.metrics["subprograms"]["step"] + # x (n cells), state_a, state_i and y per trial, the integer component exactly. + assert outcome["integer_points"] == 4 + assert outcome["points"] > 4 * 3 diff --git a/tests/test_bitexact_draws.py b/tests/test_bitexact_draws.py index 3f79caa..7b3cf46 100644 --- a/tests/test_bitexact_draws.py +++ b/tests/test_bitexact_draws.py @@ -152,26 +152,120 @@ def probe(n, lr, r): """ -def test_a_subprogram_compared_mostly_on_moved_extents_fails_by_name(tmp_path: Path) -> None: +def test_a_packed_workspace_is_grown_to_a_shape_the_body_takes(tmp_path: Path) -> None: """Every unpinned extent defaults to the same number, so a packed triangular workspace -- ``n*(n+1)/2`` long for an order ``n`` -- is a - subscript past the end. A refusal for a *shape* moves the extents, and - the draws that then fit are the small orders: a pass on those is evidence - about n = 1, not about the extents the run was configured with, so the - subprogram fails by name and says which extents to pin.""" + subscript past the end at the default, and no order it goes with makes + it not one. An extent nobody pinned is this harness's own choice, so the + choice is *grown* -- once, before the trials, and only upward -- until + the body's subscripts fit, and every trial is then compared at that one + shape rather than at whatever a per-trial redraw landed on. The metrics + say which extent grew and to what, because a reader told the points were + bit-exact is owed the shape they were bit-exact at.""" verdict = judge( tmp_path, PACKED, SimpleNamespace(w_probe=lambda n, lr, r: float(r[(int(n) * (int(n) + 1)) // 2 - 1])), ) + assert verdict.confidence is Confidence.BIT_EXACT, verdict.detail + probe = verdict.metrics["subprograms"]["probe"] + # ``n`` is a value, not an extent, and is drawn across its whole range; + # ``lr`` is the extent, and 64 covers the largest order the range holds. + assert probe["extents"] == {"lr": 64} + assert (probe["points"], probe["redrawn"], probe["reshaped"]) == (10, 0, 0) + + +SHORT = """\ +_SIGNATURES = { + "probe": { + "kind": "function", + "result": "y", + "result_dtype": "float64", + "args": [ + {"name": "lr", "intent": "IN", "dtype": "int32"}, + { + "name": "r", + "intent": "IN", + "dtype": "float64", + "dims": [{"lb": "1", "ub": "lr"}], + }, + ], + } +} + +WEIGHT = [0.25, 0.5, 0.75, 1.0] + + +def probe(lr, r): + return float(r[0] * WEIGHT[int(lr) - 1]) +""" + + +def _short(lr: Any, r: Any) -> float: + return float(r[0] * [0.25, 0.5, 0.75, 1.0][int(lr) - 1]) + + +def test_a_subprogram_compared_mostly_on_moved_extents_fails_by_name(tmp_path: Path) -> None: + """Growth only goes up, and a body that indexes a fixed table of four by + its workspace's extent takes no shape above four -- so no growth reaches + one, and the trials fall back to the shape redraw. The draws that then + fit are the short ones: a pass on those is evidence about a workspace of + one or two, not about the extents the run was configured with, so the + subprogram fails by name and says which extents to pin.""" + verdict = judge(tmp_path, SHORT, SimpleNamespace(w_probe=_short)) assert verdict.confidence is Confidence.FAILED detail = verdict.detail or "" - # ``n`` is a value, not an extent: the three trials that fit as drawn did - # so on an ``n`` the seed made small. ``lr`` is the extent that moved. - assert "probe: 7 of 10 trial(s) were compared only after the free extent(s) lr" in detail + assert "probe: 10 of 10 trial(s) were compared only after the free extent(s) lr" in detail assert "Pin `dims`" in detail +NEGATIVE_SUBSCRIPT = """\ +_SIGNATURES = { + "probe": { + "kind": "function", + "result": "y", + "result_dtype": "float64", + "args": [ + {"name": "n", "intent": "IN", "dtype": "int32"}, + { + "name": "x", + "intent": "IN", + "dtype": "float64", + "dims": [{"lb": "1", "ub": None}], + }, + ], + } +} + + +def probe(n, x): + return float(x[int(n) - 2]) +""" + + +def test_a_subscript_below_the_lower_bound_is_drawn_again_not_wrapped(tmp_path: Path) -> None: + """PCHIP's ``dpchkt`` forms ``x(n-1)`` and is only ever called with N>=2, + so ``n=1`` reads ``x(0)`` -- one before the dummy's declared lower bound. + Plain ndarray wraps a negative Python index to the array's *last* + element instead of refusing it, so the candidate would silently compare + the wrong value instead of the reference never being called on a draw + outside its own domain. It must be drawn again like any other refused + value -- and quietly: growing an extent never changes whether an index + is negative, so it must not be counted as the ``reshaped`` extent-moved + failure ``test_a_subprogram_compared_mostly_on_moved_extents_fails_by_name`` + covers.""" + + def w_probe(n: Any, x: Any) -> Any: + assert int(n) >= 2, "the reference was called on n=1, which reads before x's start" + return float(x[int(n) - 2]) + + verdict = judge(tmp_path, NEGATIVE_SUBSCRIPT, SimpleNamespace(w_probe=w_probe)) + assert verdict.confidence is Confidence.BIT_EXACT, verdict.detail + probe = verdict.metrics["subprograms"]["probe"] + assert probe["redrawn"] > 0 + assert probe["reshaped"] == 0 + + def test_pinned_extents_the_body_takes_are_not_redrawn(tmp_path: Path) -> None: """The same packed workspace at extents that fit -- ``lr`` pinned to ``n(n+1)/2`` for the pinned ``n`` -- is compared as drawn, no redraw and @@ -576,3 +670,242 @@ def w_probe(x): probe = verdict.metrics["subprograms"]["probe"] assert probe["redrawn"] > 0 assert "reference error stop" in (verdict.detail or ""), verdict.detail + + +BUFFER = """\ +import numpy as np + +_SIGNATURES = { + "fill": { + "kind": "subroutine", + "args": [ + { + "name": "x", + "intent": "IN", + "dtype": "float64", + "dims": [{"lb": "1", "ub": None}], + }, + { + "name": "y", + "intent": "OUT", + "dtype": "float64", + "dims": [{"lb": "1", "ub": None}], + "buffer": True, + }, + ], + } +} + + +def fill(x, y): + y[0] = x[0] * 2.0 + return y +""" + + +def test_a_caller_buffer_out_array_is_handed_to_the_reference_too(tmp_path: Path) -> None: + """``y`` is the caller's storage on both sides: the callee writes one cell + of it and leaves the rest as the caller had it. So the gate generates it, + hands the same values to the reference and the candidate, and reads the + reference's answer back out of the array it passed -- an f2py wrapper + spells such a dummy ``intent(in out)`` and returns nothing for it. Handed + to the candidate only, the reference is called an argument short, and the + cells it never writes are compared against a fresh allocation.""" + seen: dict[str, Any] = {} + + def w_fill(x: Any, y: Any) -> None: + seen["y"] = np.copy(y) + y[0] = x[0] * 2.0 + + verdict = BitexactVerifier().verify( + Unit(uid="draw:m", kind="subprogram"), + Candidate( + unit="draw:m", transform="test.draw", files={Path("m_numpy.py"): BUFFER.encode()} + ), + OracleRef( + unit="draw:m", + oracle="test.python-truth", + key="k", + handle={"module": SimpleNamespace(w_fill=w_fill), "wrappers": {"fill": "w_fill"}}, + ), + tmp_path, + LocalExecutor(), + {"draws": 2}, + ) + assert verdict.confidence is Confidence.BIT_EXACT, verdict.detail + assert seen["y"].any(), "the reference was handed a fresh buffer, not the caller's" + + +LOOPS = """\ +_SIGNATURES = { + "probe": { + "kind": "function", + "result": "y", + "result_dtype": "float64", + "args": [ + {"name": "mode", "intent": "IN", "dtype": "int32"}, + {"name": "x", "intent": "IN", "dtype": "float64"}, + ], + } +} + + +def probe(mode, x): + while int(mode) > 4: + pass + return x * 2.0 +""" + + +def test_a_draw_the_source_never_returns_from_is_drawn_again(tmp_path: Path) -> None: + """A fourth way a draw is not one the subprogram takes, and the only one + with nothing to raise: the source's own loop never ends on it -- + ``do while (b - a > tol)`` under a negative tolerance. The reference is + the same loop, so it must not be called on that draw either.""" + + def w_probe(mode: Any, x: Any) -> Any: + assert int(mode) <= 4, "the reference was called on a draw that never returns" + return x * 2.0 + + verdict = judge(tmp_path, LOOPS, SimpleNamespace(w_probe=w_probe), call_seconds=0.25, trials=4) + assert verdict.confidence is Confidence.BIT_EXACT, verdict.detail + assert verdict.metrics["subprograms"]["probe"]["redrawn"] > 0 + + +OPTIONAL = """\ +_SIGNATURES = { + "probe": { + "kind": "function", + "result": "y", + "result_dtype": "float64", + "args": [ + {"name": "x", "intent": "IN", "dtype": "float64"}, + {"name": "maxiter", "intent": "UNKNOWN", "dtype": "int32", "optional": True}, + ], + } +} + + +def probe(x, maxiter=None): + return x * 2.0 +""" + + +def test_an_optional_argument_of_unknown_intent_does_not_stop_the_comparison( + tmp_path: Path, +) -> None: + """``integer, optional :: maxiter`` declares no intent, and neither side + is passed it: the wrapper drops an optional dummy and the translation + spells it as a keyword sentinel. An intent nothing reads decides nothing, + and refusing over it cost ``secant`` its comparison.""" + verdict = judge(tmp_path, OPTIONAL, SimpleNamespace(w_probe=lambda x: x * 2.0)) + assert verdict.confidence is Confidence.BIT_EXACT, verdict.detail + + +def test_a_required_argument_of_unknown_intent_still_stops_it(tmp_path: Path) -> None: + """The refusal it is a relaxation of: an argument both sides are passed + whose post-call value may or may not be an output is not comparable.""" + required = OPTIONAL.replace(', "optional": True', "").replace("maxiter=None", "maxiter") + verdict = judge(tmp_path, required, SimpleNamespace(w_probe=lambda x, maxiter: x * 2.0)) + assert verdict.confidence is Confidence.FAILED + assert "maxiter have UNKNOWN intent" in (verdict.detail or "") + + +WRITER = """\ +_SIGNATURES = { + "probe": { + "kind": "subroutine", + "result": None, + "result_dtype": None, + "args": [ + {"name": "filename", "intent": "IN", "dtype": "str", "path": "created"}, + {"name": "x", "intent": "IN", "dtype": "float64"}, + ], + } +} + + +def probe(filename, x): + with open(filename, "wb") as handle: + handle.write(b"%a" % float(x)) +""" + + +def test_a_subprogram_whose_only_output_is_a_file_is_compared_on_that_file( + tmp_path: Path, +) -> None: + """No OUT argument and no result: without the file there is nothing to + pair, and the gate would have to call this uncompared. Each side is given + a scratch path of its own -- one path and the second call would overwrite + what the comparison is about to read -- and the bytes are compared.""" + written: list[str] = [] + + def w_probe(filename: Any, x: Any) -> None: + written.append(str(filename)) + with open(str(filename), "wb") as handle: + handle.write(b"%a" % float(x)) + + verdict = judge(tmp_path, WRITER, SimpleNamespace(w_probe=w_probe), trials=2) + assert verdict.confidence is Confidence.BIT_EXACT, verdict.detail + assert verdict.metrics["integer_points"] == verdict.metrics["points"] > 0 + assert len(set(written)) == len(written), "each trial draws its own path" + + +def test_a_file_that_differs_by_one_byte_is_not_a_pass(tmp_path: Path) -> None: + """The bar for a file is the bar for anything else: the same bytes. A + candidate that opened the file and wrote nothing passed every structural + check there is, which is what this gate is for.""" + + def w_probe(filename: Any, x: Any) -> None: + with open(str(filename), "wb") as handle: + handle.write(b"%a " % float(x)) + + verdict = judge(tmp_path, WRITER, SimpleNamespace(w_probe=w_probe), trials=1) + assert verdict.confidence is Confidence.FAILED + assert "filename (file)" in (verdict.detail or "") + + +def test_a_side_that_wrote_no_file_is_named(tmp_path: Path) -> None: + """An absent file is not an empty file to compare against an empty file: + the source's OPEN creates one, so nothing there is the call having done + nothing.""" + verdict = judge(tmp_path, WRITER, SimpleNamespace(w_probe=lambda filename, x: None), trials=1) + assert verdict.confidence is Confidence.FAILED + assert "the oracle left no file at the path it was given" in (verdict.detail or "") + + +def test_a_shape_the_body_checks_for_is_the_shape_it_is_drawn_at() -> None: + """``if (size(c,1) /= 5) call stop_error(...)`` is not a diagnostic aside: + it is the declaration the language had no way to make about an + assumed-shape dummy. Without it every extent is ``default_dim``, the body + stops on every draw, and the subprogram is reported as one nothing could + compare -- which says nothing about the translation.""" + from recast.verify.bitexact import DEFAULT_DIMENSION, _guarded_shapes + + required = [ + {"name": "xi", "dims": [{"lb": "1", "ub": None}]}, + {"name": "c", "dims": [{"lb": "0", "ub": None}, {"lb": "1", "ub": None}]}, + {"name": "val"}, + ] + guards = [ + {"arg": "c", "axis": 0, "extent": "5"}, + {"arg": "c", "axis": 1, "extent": "size(xi,0) - 1"}, + ] + assert _guarded_shapes(required, guards, {}) == { + "xi": [DEFAULT_DIMENSION], + "c": [5, DEFAULT_DIMENSION - 1], + } + + +def test_a_shape_guard_that_resolves_to_nothing_leaves_the_default_alone() -> None: + """An extent this cannot resolve, or one no array can have, is worse than + the default it would replace: the subprogram refusing the default says so + where a shape nobody can name would not.""" + from recast.verify.bitexact import DEFAULT_DIMENSION, _guarded_shapes + + required = [{"name": "a", "dims": [{"lb": "1", "ub": None}]}] + guards = [ + {"arg": "a", "axis": 0, "extent": "size(missing,0)"}, + {"arg": "a", "axis": 0, "extent": "0"}, + ] + assert _guarded_shapes(required, guards, {}) == {"a": [DEFAULT_DIMENSION]} diff --git a/tests/test_contract.py b/tests/test_contract.py index 0c26914..a490ebd 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -214,6 +214,39 @@ def test_recipe_stages_are_reproducible() -> None: assert first == second +def test_translate_lowers_under_the_golden_oracles_compiler_unless_told() -> None: + """The bit-exact gate compares the translation with a binary the oracle + compiles, so the transform has to lower the way that compiler does -- + ``x**2`` is ``x*x`` under gfortran and a ``pow`` call under the transform's + own default, one to two ULP apart. The recipe binds the transform's profile + to the oracle's ``fc`` (gfortran when unset) whenever that names a known + profile. The operator's word wins: ``compiler_semantics`` binds both stages + itself and must not meet a second declaration here, and an explicit + ``stages..profile`` is merged over the recipe's by the runner. + An oracle that is not one of the golden pair, or a compiler spelled as a + path, leaves the transform its default rather than guessing.""" + from recast.phases import _resolved_stage_config + + translate = BUILTIN["translate"]() + + def lowering(config: dict[str, object]) -> dict[str, object]: + (stage,) = [s for s in translate.stages(config) if s.kind == "transform"] + return stage.config + + assert lowering({}) == {"profile": "gfortran"} + assert lowering({"oracle": "f2py-golden-flat"}) == {"profile": "gfortran"} + assert lowering({"stages": {"f2py-golden": {"fc": "ifx"}}}) == {"profile": "ifx"} + assert lowering({"stages": {"f2py-golden": {"fc": "/opt/bin/gfortran-13"}}}) == {} + assert lowering({"oracle": "numpy-anchor"}) == {} + assert lowering({"compiler_semantics": "gfortran"}) == {} + assert lowering({"compiler_semantics": "ifx"}) == {} + + (stage,) = [s for s in translate.stages({}) if s.kind == "transform"] + explicit = {"stages": {"translate.numpy": {"profile": "ifx"}}} + assert _resolved_stage_config(stage, explicit) == {"profile": "ifx"} + assert _resolved_stage_config(stage, {}) == {"profile": "gfortran"} + + # --- access control is enforced, not documented ------------------------------ diff --git a/tests/test_f2py_oracle.py b/tests/test_f2py_oracle.py index 1c27fcd..89a3569 100644 --- a/tests/test_f2py_oracle.py +++ b/tests/test_f2py_oracle.py @@ -88,6 +88,71 @@ def test_wrappers_drop_optionals_and_route_generics() -> None: assert "real(8), intent(out) :: p(n)" in text # dims spelled so f2py can size them +def test_an_extent_that_is_an_intrinsic_call_is_not_a_hidden_dummy() -> None: + """An extent naming neither an argument nor a parameter becomes a hidden + integer dummy the caller supplies. ``integer :: b(size(a))`` names one of + each: ``a`` is the argument, and ``size`` is a call. Hiding it declared + ``integer, intent(in) :: size`` beside ``res(size(a))``, which gfortran + rejects twice -- PROCEDURE conflicting with INTENT, and a call to + something not PURE -- and no reference for the corpus's sorting module + could be built.""" + record = { + "module": "sort_mod", + "generics": {"argsort": ["iargsort"]}, + "subprograms": [ + { + "name": "iargsort", + "kind": "function", + "args": [ + { + "name": "a", + "dtype": "int32", + "intent": "IN", + "optional": False, + "dims": [{"lb": "1", "ub": None}], + } + ], + "result": "b", + "result_dtype": "int32", + "result_dims": [{"lb": "1", "ub": "size(a)"}], + } + ], + } + text, _ = wrappers_for(record, ["iargsort"]) + assert "subroutine w_iargsort(a, res)" in text + assert "intent(in) :: size" not in text.lower() + assert "integer, intent(out) :: res(size(a))" in text + + +def test_an_extent_naming_an_argument_in_another_case_is_not_hidden() -> None: + """Fortran does not distinguish ``N`` from ``n``. The extent keeps the + source's spelling and the argument names arrive lowercased from the + frontend, so ``real(dp) :: mesh(N+1)`` over ``integer, intent(in) :: N`` + hid an ``N`` beside the wrapper's own ``n`` -- a duplicate formal argument + gfortran refuses, which took the mesh module's three exponential-mesh + functions out of the reference build.""" + record = { + "module": "mesh", + "subprograms": [ + { + "name": "meshexp", + "kind": "function", + "args": [ + {"name": "rmin", "dtype": "float64", "intent": "IN", "optional": False}, + {"name": "n", "dtype": "int32", "intent": "IN", "optional": False}, + ], + "result": "mesh", + "result_dtype": "float64", + "result_dims": [{"lb": "1", "ub": "N + 1"}], + } + ], + } + text, _ = wrappers_for(record, ["meshexp"]) + assert "subroutine w_meshexp(rmin, n, res)" in text + assert text.lower().count("intent(in) :: n") == 1 + assert "real(8), intent(out) :: res(N + 1)" in text + + def test_out_arguments_are_defined_before_the_call() -> None: """An intent(out) dummy is undefined on entry, and a subprogram that returns early -- a guard rejecting its own arguments -- never assigns it. @@ -103,7 +168,7 @@ def test_out_arguments_are_defined_before_the_call() -> None: assert " rho = 0" not in body -def test_a_caller_buffer_out_array_is_not_defined_by_the_wrapper() -> None: +def test_a_caller_buffer_out_array_is_the_caller_s_on_both_sides() -> None: """An intent(out) array the callee cannot size -- ``dy(*)`` -- is the caller's storage on both sides: the gate generates it and hands the same values to the reference and the candidate. Zeroing it in the wrapper would @@ -145,6 +210,190 @@ def test_a_caller_buffer_out_array_is_not_defined_by_the_wrapper() -> None: assert " dy = 0" not in body +def test_a_character_out_dummy_is_defined_with_a_string() -> None: + """``ss = 0`` is a type error the compiler rejects outright -- "Cannot + convert INTEGER(4) to CHARACTER(128)" -- and it cost every module with a + character output its whole reference, not just that one wrapper.""" + record = { + "module": "text_mod", + "subprograms": [ + { + "name": "getword", + "kind": "subroutine", + "args": [ + {"name": "s", "dtype": "str", "intent": "IN", "optional": False}, + {"name": "ss", "dtype": "str", "intent": "OUT", "optional": False}, + {"name": "ok", "dtype": "bool", "intent": "OUT", "optional": False}, + ], + } + ], + } + text, _ = wrappers_for(record, ["getword"]) + assert " ss = ''" in text + assert " ok = .false." in text + + +def test_an_allocatable_dummy_is_passed_an_allocatable_actual() -> None: + """``call loadtxt(filename, d)`` with ``d`` a plain assumed-shape dummy is + "Actual argument for 'd' must be ALLOCATABLE at (1)": the call does not + compile, so the unit gets no reference at all. The wrapper keeps its + caller-side buffer -- f2py has no allocatable to offer -- and calls + through a local one.""" + record = { + "module": "io_mod", + "subprograms": [ + { + "name": "loadtxt", + "kind": "subroutine", + "args": [ + {"name": "filename", "dtype": "str", "intent": "IN", "optional": False}, + { + "name": "d", + "dtype": "float64", + "intent": "OUT", + "optional": False, + "dims": [{"lb": "1", "ub": None}, {"lb": "1", "ub": None}], + "allocatable": True, + "buffer": True, + }, + ], + } + ], + } + text, _ = wrappers_for(record, ["loadtxt"]) + body = text[text.index("subroutine w_loadtxt") : text.index("end subroutine w_loadtxt")] + assert "real(8), intent(in out) :: d(:, :)" in body + assert "real(8), allocatable :: d_alloc(:, :)" in body + assert " call loadtxt(filename, d_alloc)" in body + # What the callee allocated, as far as the caller's buffer reaches. + assert "d_n = min(shape(d), shape(d_alloc))" in body + assert "d(:d_n(1), :d_n(2)) = d_alloc(:d_n(1), :d_n(2))" in body + + +def test_a_reference_the_differential_cannot_exercise_is_named_ungated() -> None: + """The wrapper compiles; calling it is what means nothing. A character + dummy is fixed at ``len=128`` and has no draw, and an array the callee + allocates is not the buffer f2py hands back. Named, with the reason, so + the verdict can say why a public subprogram was not compared -- silence + is what the gate refuses.""" + record = { + "module": "mix_mod", + "subprograms": [ + { + "name": "loadtxt", + "kind": "subroutine", + "args": [ + {"name": "filename", "dtype": "str", "intent": "IN", "optional": False}, + { + "name": "d", + "dtype": "float64", + "intent": "OUT", + "optional": False, + "dims": [{"lb": "1", "ub": None}], + "allocatable": True, + }, + ], + }, + { + "name": "arange", + "kind": "subroutine", + "args": [ + {"name": "a", "dtype": "float64", "intent": "IN", "optional": False}, + { + "name": "u", + "dtype": "float64", + "intent": "OUT", + "optional": False, + "dims": [{"lb": "1", "ub": None}], + "allocatable": True, + }, + ], + }, + { + "name": "label", + "kind": "function", + "args": [{"name": "i", "dtype": "int32", "intent": "IN", "optional": False}], + "result": "s", + "result_dtype": "str", + }, + { + "name": "newunit", + "kind": "function", + "args": [{"name": "unit", "dtype": "int32", "intent": "OUT", "optional": True}], + "result": "n", + "result_dtype": "int32", + }, + { + # PCHIP's ``dpchfe``/``dpchfd``/``dpchcm`` shape: a mandatory + # scalar LOGICAL SKIP the caller can toggle across repeated + # calls. f2py marshals a scalar INOUT as a writable rank-0 + # array, so this compares fine. + "name": "dpchfe", + "kind": "subroutine", + "args": [ + {"name": "n", "dtype": "int32", "intent": "IN", "optional": False}, + {"name": "skip", "dtype": "bool", "intent": "INOUT", "optional": False}, + ], + }, + { + "name": "monotonic", + "kind": "subroutine", + "args": [ + {"name": "n", "dtype": "int32", "intent": "IN", "optional": False}, + {"name": "skip", "dtype": "bool", "intent": "INOUT", "optional": True}, + ], + }, + { + # PCHIP's DPCHIA/DPCHID shape: a FUNCTION that also declares + # a mandatory SKIP/IERR dummy. The verifier's + # ``_paired_outputs`` only ever pairs a function's single + # result, so this compares nothing today -- named ungated + # here rather than reaching that refusal and failing the + # whole unit's differential gate. + "name": "dpchia", + "kind": "function", + "args": [ + {"name": "n", "dtype": "int32", "intent": "IN", "optional": False}, + {"name": "skip", "dtype": "bool", "intent": "INOUT", "optional": False}, + {"name": "ierr", "dtype": "int32", "intent": "OUT", "optional": False}, + ], + "result": "value", + "result_dtype": "float64", + }, + { + # Unlike a scalar, an array LOGICAL INOUT needs f2py's + # in-place buffer, whose element size this harness's 1-byte + # bool draw does not match. + "name": "flags_inout", + "kind": "subroutine", + "args": [ + {"name": "n", "dtype": "int32", "intent": "IN", "optional": False}, + { + "name": "flags", + "dtype": "bool", + "intent": "INOUT", + "optional": False, + "dims": [{"lb": "1", "ub": None}], + }, + ], + }, + ], + } + reasons = {s["name"]: f2py_module.unexercisable(s) for s in record["subprograms"]} + assert reasons["loadtxt"].startswith("filename: character dummy") + assert reasons["arange"] == "u: allocatable array the callee sizes" + assert reasons["label"] == "character result, fixed at len=128 by the wrapper" + # An optional dummy is dropped from both calls, so it disqualifies nothing. + assert reasons["newunit"] is None + # A scalar LOGICAL INOUT, mandatory or not, compares fine. + assert reasons["dpchfe"] is None + assert reasons["monotonic"] is None + # A function's mandatory OUT/INOUT dummies have no side-effect leg to + # pair with the result, unlike a subroutine's. + assert reasons["dpchia"].startswith("declares OUT/INOUT dummy argument(s) skip, ierr") + assert reasons["flags_inout"].startswith("flags: LOGICAL INOUT array dummy") + + def test_a_dtype_the_wrapper_cannot_spell_refuses() -> None: broken = { "module": "m", @@ -168,6 +417,107 @@ def test_a_dtype_the_wrapper_cannot_spell_refuses() -> None: wrappers_for(broken, ["s"]) +def _derived_record(**overrides: object) -> dict: + """A module whose public subroutine carries its state in a derived type + of scalar components -- SLSQP's ``slsqp`` and its ``slsqpb_data``.""" + record = { + "module": "m", + "generics": {}, + "types": { + "state_t": { + "a": {"dtype": "float64", "dims": None, "allocatable": False, "pointer": False}, + "i": {"dtype": "int32", "dims": None, "allocatable": False, "pointer": False}, + } + }, + "public_types": ["state_t"], + "subprograms": [ + { + "name": "step", + "kind": "subroutine", + "public": True, + "args": [ + {"name": "n", "dtype": "int32", "intent": "IN", "optional": False}, + { + "name": "x", + "dtype": "float64", + "intent": "INOUT", + "optional": False, + "dims": [{"lb": "1", "ub": "n"}], + }, + { + "name": "state", + "dtype": "UNKNOWN(TYPE(STATE_T))", + "intent": "INOUT", + "optional": False, + }, + ], + } + ], + } + record.update(overrides) + return record + + +def test_a_derived_type_of_scalars_is_spelled_component_by_component() -> None: + """f2py cannot marshal a derived type, and a module whose only public + subprogram takes one had no reference at all. A type of scalar + components is spelled as one flat dummy per component, copied into a + local of the type before the call and back out after it, and the plan + for the candidate side names the same flat dummies in the same order.""" + record = _derived_record() + text, names = wrappers_for(record, ["step"]) + assert names == ["w_step"] + assert "subroutine w_step(n, x, state_a, state_i)" in text + assert " use m, only: step, state_t" in text + assert " type(state_t) :: state" in text + assert " real(8), intent(in out) :: state_a" in text + assert " integer, intent(in out) :: state_i" in text + assert text.index(" state%a = state_a") < text.index(" call step(n, x, state)") + assert text.index(" call step(n, x, state)") < text.index(" state_i = state%i") + assert f2py_module.unspellable(record, ["step"]) == {} + plan = f2py_module.flattened_dummies(record, ["step"]) + assert plan == { + "step": { + "state": { + "type": "state_t", + "components": [ + {"name": "state_a", "component": "a", "dtype": "float64"}, + {"name": "state_i", "component": "i", "dtype": "int32"}, + ], + } + } + } + + +def test_a_derived_type_the_wrapper_cannot_flatten_says_why() -> None: + """Refused, with the reason, rather than compiled into a wrapper that + does not build: a type the module does not export cannot be USEd, an + array component is not a scalar the flat dummy can carry, and a type + the record never defined has no components to spell.""" + private = _derived_record(public_types=[]) + with pytest.raises(ConfigError, match=r"cannot spell.*not public"): + wrappers_for(private, ["step"]) + arrays = _derived_record( + types={ + "state_t": { + "v": { + "dtype": "float64", + "dims": [{"lb": "1", "ub": "3"}], + "allocatable": False, + "pointer": False, + } + } + } + ) + with pytest.raises(ConfigError, match=r"cannot spell.*not a scalar"): + wrappers_for(arrays, ["step"]) + unknown = _derived_record(types={}) + with pytest.raises(ConfigError, match=r"cannot spell.*not defined"): + wrappers_for(unknown, ["step"]) + assert f2py_module.flattened_dummies(unknown, ["step"]) == {} + assert set(f2py_module.unspellable(unknown, ["step"])) == {"step"} + + CALLBACK_RECORD = { "module": "solve_mod", "generics": {}, @@ -241,6 +591,94 @@ def test_a_procedure_argument_becomes_an_f2py_call_back() -> None: assert text.count("required") == 1 +FUNCTION_CALLBACK_RECORD = { + "module": "optimize", + "generics": {}, + "interfaces": { + "func": { + "kind": "function", + "args": [{"name": "x", "dtype": "float64", "intent": "IN", "optional": False}], + "result": "func", + "result_dtype": "float64", + } + }, + "subprograms": [ + { + "name": "bisect", + "kind": "function", + "args": [ + { + "name": "f", + "dtype": "PROCEDURE", + "intent": "IN", + "optional": False, + "procedure": True, + "interface": "func", + }, + {"name": "a", "dtype": "float64", "intent": "IN", "optional": False}, + {"name": "b", "dtype": "float64", "intent": "IN", "optional": False}, + {"name": "tol", "dtype": "float64", "intent": "IN", "optional": False}, + ], + "result": "c", + "result_dtype": "float64", + } + ], +} + + +def test_a_function_procedure_argument_becomes_an_f2py_call_back() -> None: + """crackfortran tells a function call-back from a subroutine one by the + statement that uses it: a CALL is a subroutine, and an assignment whose + right-hand side calls the dummy is a function whose result type is the + assigned variable's. The dummy carries that type in real Fortran too, + because the wrapper is ``implicit none``.""" + text, names = wrappers_for(FUNCTION_CALLBACK_RECORD, ["bisect"]) + assert names == ["w_bisect"] + assert " real(8), external :: f" in text + assert "!f2py real(8), intent(in) :: cb_f_x" in text + assert "!f2py real(8) :: cb_f_res" in text + assert "!f2py cb_f_res = f(cb_f_x)" in text + # The declaration has to reach crackfortran before the line that assigns + # to it, or the call-back's result has no type. + assert text.index("real(8) :: cb_f_res") < text.index("cb_f_res = f(") + + +def test_a_function_call_back_that_writes_an_argument_refuses() -> None: + """Its result and its written argument both come back, and which one f2py + hands over first is not a convention this wrapper shares with the + translation.""" + record = { + **FUNCTION_CALLBACK_RECORD, + "interfaces": { + "func": { + "kind": "function", + "args": [ + {"name": "x", "dtype": "float64", "intent": "IN", "optional": False}, + {"name": "ierr", "dtype": "int32", "intent": "OUT", "optional": False}, + ], + "result": "func", + "result_dtype": "float64", + } + }, + } + with pytest.raises(ConfigError, match=r"only\s+read theirs"): + wrappers_for(record, ["bisect"]) + + +def test_the_harness_builds_a_function_call_back() -> None: + """A function call-back answers through its return on both sides, so the + stand-in returns one value rather than a tuple of written arguments.""" + import numpy as np + + from recast.verify.bitexact import callback_for + + callback = callback_for(np, "f", FUNCTION_CALLBACK_RECORD["interfaces"]["func"]) + assert callback.__code__.co_argcount == 1 + value = callback(np.float64(2.0)) + assert isinstance(value, np.floating) + assert value == callback(np.float64(2.0)) # deterministic + + def test_a_procedure_argument_with_no_interface_refuses() -> None: """``procedure() :: fcn`` says a name is callable and nothing about the call. There is nothing to declare, and guessing is not an option.""" @@ -443,7 +881,6 @@ def test_f2py_logical_inout_fails_closed_before_execution(tmp_path: Path) -> Non "flip": { "kind": "subroutine", "args": [ - {"name": "x", "dtype": "bool", "intent": "INOUT", "optional": False}, {"name": "a", "dtype": "bool", "intent": "INOUT", "optional": False, "dims": [{"lb": "1", "ub": "3"}]}, {"name": "y", "dtype": "bool", "intent": "OUT", "optional": False}, @@ -453,18 +890,18 @@ def test_f2py_logical_inout_fails_closed_before_execution(tmp_path: Path) -> Non } } -def flip(x, a): +def flip(a): raise AssertionError("candidate subroutine must not execute") """ candidate = Candidate( - unit="fortran:logical_inout", + unit="fortran:logical_inout_array", transform="translate.numpy", - files={Path("logical_inout_numpy.py"): emitted}, + files={Path("logical_inout_array_numpy.py"): emitted}, ) class Truth: @staticmethod - def w_flip(x, a): + def w_flip(a): raise AssertionError("oracle subroutine must not execute") ref = OracleRef( @@ -589,6 +1026,57 @@ def w_identity(x): assert verdict.metrics["points"] == 3 +def test_f2py_badname_argument_is_spelled_with_its_bn_suffix(tmp_path: Path) -> None: + """A dummy that collides with a C keyword -- PCHIP's ``dpchic`` declares + one named ``switch`` -- is not the keyword the compiled reference answers + to. f2py's own ``crackfortran`` frontend renames every name in its + ``badnames`` table to ``_bn`` before it reaches the extension, so + the reference call must spell it that way too, not lowercased verbatim.""" + emitted = b"""\ +_SIGNATURES = { + "scale": { + "kind": "function", + "args": [ + {"name": "switch", "dtype": "float64", "intent": "IN", "optional": False}, + ], + "result": "y", + "result_dtype": "float64", + } +} + +def scale(switch): + return switch * 2.0 +""" + candidate = Candidate( + unit="fortran:badname_argument", + transform="translate.numpy", + files={Path("badname_argument_numpy.py"): emitted}, + ) + + class Truth: + @staticmethod + def w_scale(switch_bn): + return switch_bn * 2.0 + + ref = OracleRef( + unit=candidate.unit, + oracle="f2py-golden", + key="k", + handle={"module": Truth(), "wrappers": {"scale": "w_scale"}}, + ) + verdict = BitexactVerifier().verify( + Unit(uid=candidate.unit, kind="module"), + candidate, + ref, + tmp_path / "work", + LocalExecutor(), + {"trials": 3}, + ) + + assert verdict.confidence is Confidence.BIT_EXACT, verdict.detail + assert verdict.metrics["points"] == 3 + + def test_function_dummy_side_effects_fail_closed_before_execution(tmp_path: Path) -> None: """A result-only comparison must not silently ignore an INOUT dummy.""" emitted = b"""\ @@ -1175,12 +1663,80 @@ def test_logical_values_are_bit_exact_against_real_f2py(tmp_path: Path) -> None: assert verdict.metrics["bit_exact"] == 10 +PACKED_SOURCE = """\ +module packed_workspace + implicit none +contains + subroutine solve(n, lr, r, qtb, x) + integer, intent(in) :: n + integer, intent(in) :: lr + real(8), intent(in) :: r(lr) + real(8), intent(in) :: qtb(n) + real(8), intent(out) :: x(n) + integer :: i, j, jj, jp1, k, l + real(8) :: sm + jj = (n*(n + 1))/2 + 1 + do k = 1, n + j = n - k + 1 + jp1 = j + 1 + jj = jj - k + l = jj + 1 + sm = 0.0d0 + if (n >= jp1) then + do i = jp1, n + sm = sm + r(l)*x(i) + l = l + 1 + end do + end if + x(j) = (qtb(j) - sm)/r(jj) + end do + end subroutine solve +end module packed_workspace +""" +"""MINPACK's ``dogleg`` back-substitution: ``r(lr)`` holds the upper triangle +of an order-``n`` matrix, so the subprogram takes no draw where ``lr`` is +``n`` -- which is what every extent nobody pinned defaults to.""" + + +@pytest.mark.skipif( + GFORTRAN is None or not MESON, + reason="needs a Fortran compiler and the meson backend (recast-engine[verify])", +) +def test_a_packed_workspace_is_compared_against_real_f2py(tmp_path: Path) -> None: + """The extent is grown to a shape the body takes and the whole spine runs + on it: without that, the first subscript the translation forms is past the + end of ``r`` at every trial, and the subprogram is one no draw compared.""" + (tmp_path / "packed_workspace.f90").write_text(PACKED_SOURCE) + workspace = tmp_path / "work" + workspace.mkdir() + executor = LocalExecutor() + + frontend = FortranFrontend() + unit = next(u for u in frontend.discover(tmp_path) if u.kind == "module") + facts = frontend.analyze(unit, tmp_path) + candidate = NumpyTranslation().apply(unit, facts, {"root": tmp_path}) + assert candidate.deferred == [] + + config = {"root": tmp_path, "fc": GFORTRAN, "trials": 4} + ref = F2pyGoldenOracle().materialize(unit, facts, workspace, executor, config) + verdict = BitexactVerifier().verify(unit, candidate, ref, workspace, executor, config) + + assert verdict.confidence is Confidence.BIT_EXACT, verdict.detail + solve = verdict.metrics["subprograms"]["solve"] + # The order stays at the default the run was configured with; the + # workspace is what grew, and the outcome says so. + assert solve["extents"] == {"lr": 64} + assert (solve["points"], solve["redrawn"], solve["reshaped"]) == (32, 0, 0) + + @pytest.mark.skipif( GFORTRAN is None or not MESON, reason="needs a Fortran compiler and the meson backend (recast-engine[verify])", ) -def test_real_f2py_logical_inout_fails_closed(tmp_path: Path) -> None: - """The real ABI hazard is reported, never mistaken for a mismatch.""" +def test_real_f2py_logical_inout_array_fails_closed_scalar_compares(tmp_path: Path) -> None: + """The real array ABI hazard is reported, never mistaken for a mismatch, + but a scalar LOGICAL INOUT in the same module -- PCHIP's ``dpchfe`` + shape -- still gets compared against the real compiled reference.""" (tmp_path / "logical_inout.f90").write_text(LOGICAL_INOUT_SOURCE) workspace = tmp_path / "work" workspace.mkdir() @@ -1196,8 +1752,9 @@ def test_real_f2py_logical_inout_fails_closed(tmp_path: Path) -> None: ref = F2pyGoldenOracle().materialize(unit, facts, workspace, executor, config) verdict = BitexactVerifier().verify(unit, candidate, ref, workspace, executor, config) - assert verdict.confidence is Confidence.FAILED - assert "no portable Python buffer ABI" in verdict.detail + assert verdict.confidence is Confidence.BIT_EXACT, verdict.detail + assert "flip_scalar" in verdict.metrics["subprograms"] + assert "LOGICAL INOUT array dummy" in verdict.metrics["ungated"]["flip_array"] @pytest.mark.skipif( @@ -1412,6 +1969,114 @@ def test_the_oracle_defaults_to_public_subprograms() -> None: assert F2pyGoldenOracle._subprograms(facts, {"subprograms": ["detail"]}) == ["detail"] +def test_a_specific_of_a_public_generic_is_reachable_though_its_name_is_not() -> None: + """A module may publish nothing but generics: the corpus's sorting module + is ``private`` with ``public sort, sortpairs, argsort`` over twelve + specifics, every one of them private. Selecting on the specific's own + accessibility left nothing to wrap and no reference to build, while + ``wrappers_for`` stood ready to call each one through its generic -- which + is the name the wrapper ``use``s, and which is public. + """ + from recast.model import Facts + + facts = Facts( + unit="fortran:sorting", + interface={ + "module": "sorting", + "public": ["sort", "sortpairs"], + "generics": {"sort": ["sortnums"], "sortpairs": ["sortnumnumpairs"], "hidden": ["aux"]}, + "subprograms": [ + { + "name": "sortnums", + "kind": "subroutine", + "public": False, + "args": [ + { + "name": "nums", + "dtype": "float64", + "intent": "INOUT", + "optional": False, + "dims": [{"lb": "1", "ub": None}], + } + ], + }, + { + "name": "sortnumnumpairs", + "kind": "subroutine", + "public": False, + "args": [ + { + "name": "nums1", + "dtype": "float64", + "intent": "INOUT", + "optional": False, + "dims": [{"lb": "1", "ub": None}], + } + ], + }, + # Behind a generic nothing published: still unreachable. + {"name": "aux", "kind": "subroutine", "public": False, "args": []}, + ], + }, + ) + assert F2pyGoldenOracle._subprograms(facts, {}) == ["sortnums", "sortnumnumpairs"] + + +def test_a_reached_specific_the_wrapper_cannot_spell_is_dropped_not_fatal() -> None: + """Reached, not exported. ``sortpairs`` also covers a COMPLEX overload, + which ``FORTRAN_TYPES`` has no spelling for; raising on it would cost the + other ten specifics their reference for the sake of one the module never + named. A *public* name of the same dtype is still an error, because the + module says it is part of its surface. + """ + from recast.model import Facts + + def module(public_specific: bool) -> dict[str, object]: + return { + "module": "sorting", + "public": ["sortpairs"], + "generics": {"sortpairs": ["real_pairs", "complex_pairs"]}, + "subprograms": [ + { + "name": "real_pairs", + "kind": "subroutine", + "public": False, + "args": [ + { + "name": "nums", + "dtype": "float64", + "intent": "INOUT", + "optional": False, + "dims": [{"lb": "1", "ub": None}], + } + ], + }, + { + "name": "complex_pairs", + "kind": "subroutine", + "public": public_specific, + "args": [ + { + "name": "nums", + "dtype": "UNKNOWN(COMPLEX)", + "intent": "INOUT", + "optional": False, + "dims": [{"lb": "1", "ub": None}], + } + ], + }, + ], + } + + reached = Facts(unit="fortran:sorting", interface=module(public_specific=False)) + assert F2pyGoldenOracle._subprograms(reached, {}) == ["real_pairs"] + + exported = Facts(unit="fortran:sorting", interface=module(public_specific=True)) + assert F2pyGoldenOracle._subprograms(exported, {}) == ["real_pairs", "complex_pairs"] + with pytest.raises(ConfigError, match="cannot spell"): + wrappers_for(exported.interface, ["complex_pairs"]) + + def test_wrappers_serve_a_file_of_bare_subprograms() -> None: """A file with no module borrows its stem for a name, so a `use` line would not compile -- the callee is an external. Dimension names the file @@ -1648,6 +2313,40 @@ def test_the_reference_names_the_siblings_the_unit_uses(tmp_path: Path) -> None: assert companion_sources(facts, tmp_path) == [(tmp_path / "toy_kinds.f90").resolve()] +USER_SOURCE = """\ +module toy_user + use toy_split, only: scale_all + implicit none +contains + subroutine drive(n, x) + integer, intent(in) :: n + real, intent(inout) :: x(*) + call scale_all(n, 2.0, x) + end subroutine drive +end module toy_user +""" + + +def test_the_reference_also_gets_what_the_siblings_themselves_use(tmp_path: Path) -> None: + """``toy_user`` cannot see ``toy_kinds``: ``toy_split`` answers for the + only-list asked of it, so nothing of ``toy_kinds`` is in scope here. The + build still needs the file -- ``toy_split.f90`` is compiled from source, + and gfortran stops at "cannot open module file toy_kinds.mod" -- so the + closure is staged, dependencies first.""" + from recast.oracle.f2py import companion_sources + + (tmp_path / "toy_kinds.f90").write_text(KINDS_SOURCE) + (tmp_path / "toy_split.f90").write_text(SPLIT_SOURCE) + (tmp_path / "toy_user.f90").write_text(USER_SOURCE) + frontend = FortranFrontend() + unit = next(u for u in frontend.discover(tmp_path) if u.uid == "fortran:toy_user") + facts = frontend.analyze(unit, tmp_path) + assert companion_sources(facts, tmp_path) == [ + (tmp_path / "toy_kinds.f90").resolve(), + (tmp_path / "toy_split.f90").resolve(), + ] + + def test_f2py_only_receives_canonical_source_and_include_tokens( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -2123,3 +2822,276 @@ def test_an_error_stop_in_the_reference_is_a_report_not_a_dead_run(tmp_path: Pat again = BitexactVerifier().verify(unit, candidate, ref, workspace, executor, config) assert again.confidence is Confidence.BIT_EXACT, again.detail assert ref.handle["module"].restarts == 1 + + +def test_a_path_the_body_creates_is_exercisable_and_one_it_reads_is_not() -> None: + """A character dummy has no draw in general. A path an OPEN in the body + *creates* does: any name works, because the subprogram makes the file + rather than finding one, and the gate compares what each side left there. + A path opened ``STATUS='OLD'`` would need a draw that is a file already + holding something, so it stays ungated -- and says so in those words, + because "fixed at len=128" is not why.""" + record = { + "module": "io_mod", + "generics": {}, + "subprograms": [ + { + "name": "saveppm", + "kind": "subroutine", + "args": [ + { + "name": "filename", + "dtype": "str", + "intent": "IN", + "optional": False, + "path": "created", + }, + { + "name": "img", + "dtype": "int32", + "intent": "IN", + "optional": False, + "dims": [{"lb": "1", "ub": None}, {"lb": "1", "ub": None}], + }, + ], + }, + { + "name": "loadppm", + "kind": "subroutine", + "args": [ + { + "name": "filename", + "dtype": "str", + "intent": "IN", + "optional": False, + "path": "existing", + }, + {"name": "n", "dtype": "int32", "intent": "OUT", "optional": False}, + ], + }, + ], + } + reasons = {s["name"]: f2py_module.unexercisable(s) for s in record["subprograms"]} + assert reasons["saveppm"] is None + assert reasons["loadppm"] == ( + "filename: names a file the body opens STATUS='OLD', which no generated draw can put there" + ) + text, _ = wrappers_for(record, ["saveppm"]) + # TRIM, because the wrapper's dummy is padded to 128 and the callee's is + # ``len=*``: without it the callee sees a length no caller ever passes. + assert " call saveppm(trim(filename), img)" in text + + +FILE_SOURCE = """\ +module file_mod + implicit none +contains + subroutine save_grid(filename, n, d) + character(len=*), intent(in) :: filename + integer, intent(in) :: n + real(8), intent(in) :: d(n) + integer :: u, i + open(newunit=u, file=filename, status="replace") + write(u, '(i0)') n + do i = 1, n + write(u, '(3a1)', advance='no') achar(modulo(int(d(i)), 60) + 40) + end do + write(u,*) d + close(u) + end subroutine save_grid + + subroutine load_grid(filename, n) + character(len=*), intent(in) :: filename + integer, intent(out) :: n + integer :: u + open(newunit=u, file=filename, status="old") + read(u, '(i6)') n + close(u) + end subroutine load_grid +end module file_mod +""" + + +@pytest.mark.skipif( + GFORTRAN is None or not MESON, + reason="needs a Fortran compiler and the meson backend (recast-engine[verify])", +) +def test_a_subprogram_whose_only_product_is_a_file_is_gated_on_that_file( + tmp_path: Path, +) -> None: + """``save_grid`` declares two inputs and nothing else: every output the + harness could pair is absent, and its whole result is the file it writes. + So the file is the output -- each side writes its own, and the bytes are + compared. ``load_grid`` reads a file that has to already exist, which no + draw can produce, and stays ungated with that as the reason. + + The stub this replaces made the point sharply: the emitted ``save_grid`` + opened a file, wrote nothing to it and returned, and passed every + structural check on the way. + """ + (tmp_path / "file_mod.f90").write_text(FILE_SOURCE) + workspace = tmp_path / "work" + workspace.mkdir() + executor = LocalExecutor() + + frontend = FortranFrontend() + unit = next(u for u in frontend.discover(tmp_path) if u.kind == "module") + facts = frontend.analyze(unit, tmp_path) + candidate = NumpyTranslation().apply(unit, facts, {"root": tmp_path}) + assert candidate.deferred == [] + + config = {"root": tmp_path, "fc": GFORTRAN, "trials": 3, "dims": {"n": 6}} + ref = F2pyGoldenOracle().materialize(unit, facts, workspace, executor, config) + assert ref.handle["ungated"] == { + "load_grid": "filename: names a file the body opens STATUS='OLD', " + "which no generated draw can put there" + } + + verdict = BitexactVerifier().verify(unit, candidate, ref, workspace, executor, config) + assert verdict.confidence is Confidence.BIT_EXACT, verdict.detail + assert verdict.metrics["bit_exact"] == verdict.metrics["points"] > 0 + # Every point is a byte of the file: the subprogram has no other output. + assert verdict.metrics["integer_points"] == verdict.metrics["points"] + assert set(verdict.metrics["subprograms"]) == {"save_grid"} + assert "load_grid" in verdict.metrics["ungated"] + + +LIBRARY_RECORDS = [ + { + "module": "solver", + "subprograms": [ + {"name": "go", "calls": ["helper"], "external_calls": ["solve_it"]}, + {"name": "helper", "calls": [], "external_calls": []}, + {"name": "outer", "calls": ["go"], "external_calls": []}, + ], + "interfaces": {}, + }, + { + "module": "libwrap", + "subprograms": [], + "interfaces": { + "solve_it": {"name": "solve_it", "kind": "subroutine"}, + "helper": {"name": "helper", "kind": "subroutine"}, + "shape_t": {"name": "shape_t"}, + }, + }, +] + + +def test_a_procedure_declared_by_an_interface_and_defined_nowhere_is_named() -> None: + """The reference build links nothing but the sources staged for it, so a + name only an INTERFACE block declares is a symbol nothing defines: the + extension links with it undefined and *importing* it fails, which costs + the module's every other subprogram its reference too. ``helper`` is + declared the same way and defined in the tree, so it is not one.""" + assert f2py_module.undefined_externals(LIBRARY_RECORDS, []) == ["solve_it"] + + +def test_a_definition_the_operator_added_from_outside_the_tree_is_not_stubbed( + tmp_path: Path, +) -> None: + """``extra_sources`` is where a build gets what the tree does not hold. + Stubbing a name that source already defines is a duplicate symbol where + there was a working reference -- and its own interface block, which is a + declaration, must not be read as the definition.""" + extra = tmp_path / "lib.f90" + extra.write_text( + "interface\n subroutine solve_it(n)\n end subroutine\nend interface\n" + "subroutine solve_it(n)\n integer :: n\nend subroutine solve_it\n" + ) + assert f2py_module.undefined_externals(LIBRARY_RECORDS, [extra]) == [] + + +def test_every_caller_of_a_missing_library_is_found_through_the_call_graph() -> None: + """``outer`` calls ``go``, which calls ``solve_it``. Neither can be run + against a reference whose ``solve_it`` is a refusal, and only the closure + says so about the first one.""" + reached = f2py_module.reaching(LIBRARY_RECORDS, {"solve_it"}) + assert reached == {"go": "solve_it", "outer": "solve_it"} + + +def test_a_stub_for_a_missing_library_refuses_rather_than_returns() -> None: + """The reference exists to say what the original program computes, and for + a call into a library this build does not have it cannot say. The symbol + resolves, so the extension loads and the subprograms that never reach the + library are compared as usual; anything that does reach it stops, naming + the routine, rather than returning a number nobody computed.""" + text = f2py_module.unresolved_stubs(["solve_it"]) + assert "subroutine solve_it()" in text + assert "error stop" in text and "solve_it has no definition" in text + + +LIBRARY_INTERFACE_SOURCE = """\ +module tiny_lapack + implicit none + interface + subroutine dgesv(n, nrhs, a, lda, ipiv, b, ldb, info) + integer :: info, lda, ldb, n, nrhs + integer :: ipiv(*) + double precision :: a(lda,*), b(ldb,*) + end subroutine + end interface +end module tiny_lapack +""" + +LIBRARY_CALLER_SOURCE = """\ +module tiny_solver + use tiny_lapack, only: dgesv + implicit none +contains + subroutine solve3(a, rhs, x) + double precision, intent(in) :: a(3,3) + double precision, intent(in) :: rhs(3) + double precision, intent(out) :: x(3) + double precision :: work(3,3), b(3,1) + integer :: ipiv(3), info + work = a + b(:,1) = rhs + call dgesv(3, 1, work, 3, ipiv, b, 3, info) + x = b(:,1) + end subroutine solve3 +end module tiny_solver +""" + + +@pytest.mark.skipif( + GFORTRAN is None or not MESON, + reason="needs a Fortran compiler and the meson backend (recast-engine[verify])", +) +def test_a_call_into_a_declared_only_library_is_compared_not_disclaimed(tmp_path: Path) -> None: + """The shape the corpus's ``splines`` has, end to end. + + ``tiny_lapack`` is interface blocks and nothing else -- the bodies are in + a library neither build links -- so ``dgesv`` used to get a body that + error-stops on the reference side and nothing at all on the candidate's, + and every subprogram reaching it came out ungated: the unit passed with + its one real subprogram never compared. ``recast.references`` defines it + on both sides instead, from one implementation, so ``solve3`` is compared + like anything else -- and the verdict says what stood in for the library, + because the numbers it was compared at are not the ones LAPACK would have + produced. + """ + (tmp_path / "tiny_lapack.f90").write_text(LIBRARY_INTERFACE_SOURCE) + (tmp_path / "tiny_solver.f90").write_text(LIBRARY_CALLER_SOURCE) + workspace = tmp_path / "work" + workspace.mkdir() + executor = LocalExecutor() + + frontend = FortranFrontend() + unit = next(u for u in frontend.discover(tmp_path) if u.uid == "fortran:tiny_solver") + facts = frontend.analyze(unit, tmp_path) + candidate = NumpyTranslation().apply(unit, facts, {"root": tmp_path, "profile": "gfortran"}) + assert candidate.deferred == [] + + config = {"root": tmp_path, "fc": GFORTRAN, "trials": 5} + ref = F2pyGoldenOracle().materialize(unit, facts, workspace, executor, config) + assert ref.handle["ungated"] == {}, "nothing reaches a procedure with no definition now" + assert "dgesv" in ref.handle["substituted"] + + verdict = BitexactVerifier().verify(unit, candidate, ref, workspace, executor, config) + assert verdict.confidence is Confidence.BIT_EXACT, verdict.detail + assert verdict.metrics["points"] == 15 + assert verdict.metrics["bit_exact"] == 15 + assert "ungated" not in verdict.metrics + assert set(verdict.metrics["substituted"]) == {"dgesv"} + assert "stood in for by recast's own reference implementation" in verdict.detail diff --git a/tests/test_fortran_analysis.py b/tests/test_fortran_analysis.py index 81d7df0..afb7536 100644 --- a/tests/test_fortran_analysis.py +++ b/tests/test_fortran_analysis.py @@ -158,6 +158,51 @@ def test_derived_type_components_report_allocatable_and_pointer(tmp_path: Path) assert (grid["dx"]["allocatable"], grid["dx"]["pointer"]) == (False, False) +def test_public_types_follow_the_type_statement_and_the_module_default(tmp_path: Path) -> None: + """A reference wrapper that spells a derived-type dummy component by + component has to ``use`` the type, so which types the module exports is + a fact the record carries. ``type, public :: t`` decides on the type + statement, which the ``Access_Stmt`` walk behind ``public`` never sees; + a type saying nothing follows the module's default and its lists.""" + hidden = _write( + tmp_path, + "hidden.f90", + """\ +module hidden_mod + implicit none + private + public :: listed_t + type, public :: marked_t + real :: a + end type marked_t + type :: listed_t + real :: b + end type listed_t + type :: quiet_t + real :: c + end type quiet_t +end module hidden_mod +""", + ) + assert interface.extract(hidden)["public_types"] == ["listed_t", "marked_t"] + open_ = _write( + tmp_path, + "open.f90", + """\ +module open_mod + implicit none + type :: shown_t + real :: a + end type shown_t + type, private :: kept_t + real :: b + end type kept_t +end module open_mod +""", + ) + assert interface.extract(open_)["public_types"] == ["shown_t"] + + def test_a_component_carries_a_shape_spelled_on_the_dimension_attribute(tmp_path: Path) -> None: """``real, dimension(4) :: edge`` says what ``real :: edge(4)`` says, and reading only the second reported the component as a scalar -- not a @@ -427,6 +472,43 @@ def test_declaration_bounds_are_hoisted_too(tmp_path: Path) -> None: assert any(loc.endswith(":decl") for loc in got["hoisted_literals"]["I_5"]["locations"]) +def test_local_parameter_initializers_are_hoisted_too(tmp_path: Path) -> None: + """``cos(94.0_wp*deg2rad)`` sits in the specification part, where the + execution-part sweep never looks. The prologue re-renders it as a local + assignment and needs a name for the 94.0 the way any statement would; + a bare or whitelisted literal is still left alone.""" + src = """\ +module rot_mod + implicit none + integer, parameter :: wp = kind(1.0d0) + real(wp), parameter :: deg2rad = acos(-1.0_wp) / 180.0_wp +contains + subroutine rot(x) + real(wp), intent(inout) :: x + real(wp), parameter :: cosr = cos(94.0_wp * deg2rad) + real(wp), parameter :: small = 10.0_wp**int(log(epsilon(1.0_wp))) + real(wp), parameter :: half = 0.5_wp + real(wp), parameter :: plain = 3.5_wp + parameter (twist = sin(86.0_wp * deg2rad)) + real(wp) :: twist + x = x * cosr + small + half + plain + twist + end subroutine rot +end module rot_mod +""" + got = constants.extract(_write(tmp_path, "rot.f90", src)) + hoisted = got["hoisted_literals"] + assert got["literal_map"]["rot"]["94.0_wp"] == "F_94P0" + assert got["literal_map"]["rot"]["10.0_wp"] == "F_10P0" + assert got["literal_map"]["rot"]["86.0_wp"] == "F_86P0" + for name in ("F_94P0", "F_10P0", "F_86P0"): + assert hoisted[name]["locations"] == ["rot:param"] + # The module-level initializer is the constants file's alone: not hoisted. + assert "F_180P0" not in hoisted + # Whitelisted values keep being written out where they are read. + assert "0.5_wp" not in got["literal_map"]["rot"] + assert "1.0_wp" not in got["literal_map"]["rot"] + + def test_an_unclassifiable_initializer_refuses(tmp_path: Path) -> None: """An initializer over a name nothing defines is reported as unresolved rather than approximated.""" @@ -756,6 +838,19 @@ def test_a_file_of_bare_subprograms_borrows_the_file_stem(tmp_path: Path) -> Non real, intent(out) :: y(:) y = x end subroutine sink + function bump(x, cnt) result(y) + real, intent(in) :: x + integer, intent(inout) :: cnt + real :: y + cnt = cnt + 1 + y = x * 2.0 + end function bump + subroutine search(x, cnt, alpha) + real, intent(in) :: x + integer, intent(inout) :: cnt + real, intent(out) :: alpha + alpha = bump(x, cnt) + end subroutine search end module rw_mod """ @@ -803,6 +898,15 @@ def test_allocate_and_deallocate_are_writes(tmp_path: Path) -> None: assert blocks["B005"] == {"id": "B005", "reads": [], "writes": ["pool"]} +def test_a_function_reference_writes_its_inout_actuals(tmp_path: Path) -> None: + """``alpha = bump(x, cnt)`` changes ``cnt`` as surely as a CALL would; + the translation hands it back beside the result and unpacks it, so this + side counts the write too, or the two disagreed on every line search.""" + block = _blocks(tmp_path, "search")["B001"] + assert block["reads"] == ["cnt", "x"] + assert block["writes"] == ["alpha", "cnt"] + + def test_a_call_splits_its_arguments_by_declared_intent(tmp_path: Path) -> None: call = _blocks(tmp_path)["B006"] # ``y(:)`` is an intent(out) the callee cannot size, so it is the @@ -845,7 +949,8 @@ def test_a_component_name_is_not_a_read_on_the_out_argument_path(tmp_path: Path) answer: CLUBB's pdf_closure passes ``pdf_params%chi_1`` and six more components as OUT actuals, the candidate spells attributes and reads no variable of those names, and the gate scored six blocks as disagreeing - over reads of variables the scope does not have. + over reads of variables the scope does not have. SLSQP's + ``slsqpb(..., sdat%t, sdat%f0, ...)`` is the same shape. """ from recast.fortran import rwset @@ -858,7 +963,7 @@ def test_a_component_name_is_not_a_read_on_the_out_argument_path(tmp_path: Path) ) blocks = {b["id"]: b for b in rwset.block_rwsets(node, rwset.scope_for(record, "drive"))} # ``slot(:)`` is a caller-buffer OUT (#36), so ``b`` is read as well as - # written on the call (#38); ``q`` is an attribute on both paths. + # written on the call (#38); ``q`` is an attribute on both paths, not a read. assert blocks["B001"] == {"id": "B001", "reads": ["b", "n"], "writes": ["b"]}, "out-argument" assert blocks["B002"] == {"id": "B002", "reads": ["n"], "writes": ["b"]}, "assignment" @@ -886,6 +991,55 @@ def test_a_local_shadows_an_intrinsic_of_the_same_name(tmp_path: Path) -> None: assert blocks[1] == {"id": "B002", "reads": ["sum"], "writes": ["out"]} +def test_locating_an_extreme_value_is_a_call_and_not_a_read(tmp_path: Path) -> None: + """``imin = minloc(a2(i:), 1) + i - 1`` reads ``a2`` and ``i``. Reading a + variable called ``minloc`` too is a read the translation -- ``np.argmin`` + -- does not make, and it failed the only two blocks of the corpus's + sorting module that the static rwset gate rejected.""" + from recast.fortran import rwset + + src = _write( + tmp_path, + "locate.f90", + "module l_mod\ncontains\n" + " subroutine go(a2, i, imin)\n" + " real, intent(in) :: a2(:)\n" + " integer, intent(in) :: i\n" + " integer, intent(out) :: imin\n" + " imin = minloc(a2(i:), 1) + i - 1\n" + " end subroutine go\n" + "end module l_mod\n", + ) + record = interface.extract(src, kind_assumptions=KINDS) + node = walk(parse(src), f03.Subroutine_Subprogram)[0] + blocks = rwset.block_rwsets(node, rwset.scope_for(record, "go")) + assert blocks[0] == {"id": "B001", "reads": ["a2", "i"], "writes": ["imin"]} + + +def test_replicating_an_array_is_a_call_and_not_a_read(tmp_path: Path) -> None: + """``x2 = spread(x, 1, size(y))`` reads ``x`` and ``y``. Reading a variable + called ``spread`` too is a read the translation -- ``np.repeat`` -- does + not make, and it failed both blocks of ``meshgrid`` in the corpus's mesh + module.""" + from recast.fortran import rwset + + src = _write( + tmp_path, + "grid.f90", + "module g_mod\ncontains\n" + " subroutine go(x, y, x2)\n" + " real, intent(in) :: x(:), y(:)\n" + " real, intent(out) :: x2(:, :)\n" + " x2 = spread(x, 1, size(y))\n" + " end subroutine go\n" + "end module g_mod\n", + ) + record = interface.extract(src, kind_assumptions=KINDS) + node = walk(parse(src), f03.Subroutine_Subprogram)[0] + blocks = rwset.block_rwsets(node, rwset.scope_for(record, "go")) + assert blocks[0] == {"id": "B001", "reads": ["x", "y"], "writes": ["x2"]} + + def test_the_intrinsic_table_carries_names_not_translations(tmp_path: Path) -> None: """The read/write analysis asked "is this an intrinsic" and never once what it maps to, so only the names are a Fortran fact. Keeping the mapping out @@ -1071,6 +1225,85 @@ def test_companion_externals_carry_the_siblings_intents(tmp_path: Path) -> None: assert "wv_sat_svp_water" not in informed_blocks["B003"]["reads"] +def test_a_siblings_inout_position_is_read_as_well_as_written(tmp_path: Path) -> None: + """``dscal(n, alpha, s, 1)`` into a translated BLAS sibling: the callee's + ``dx`` is intent(inout), and the emitted call passes ``s`` in and unpacks + it, so the target side reads it. With the sibling's table saying only + which positions are written, this side recorded the write alone, and + every INOUT call into a sibling disagreed (SLSQP's line searches).""" + from recast.fortran import rwset + + record = interface.extract(_write(tmp_path, "shadow.f90", SHADOWED), kind_assumptions=KINDS) + node = _sub_node(tmp_path, "interpolate") + written_only = rwset.scope_for( + record, + "interpolate", + externals={"qsat_water": {"kind": "subroutine", "out_positions": [2, 3]}}, + ) + blocks = {b["id"]: b for b in rwset.block_rwsets(node, written_only)} + assert "gamma" in blocks["B002"]["writes"] and "gamma" not in blocks["B002"]["reads"] + + # A position the caller reads as well as writes -- an INOUT dummy -- is + # named in ``read_positions`` (upstream's encoding of what the lab spelled + # as a separate ``inout_positions``: the written positions that are also + # read are exactly ``out_positions & read_positions``). Position 2 is that + # position here. + inout = rwset.scope_for( + record, + "interpolate", + externals={ + "qsat_water": { + "kind": "subroutine", + "out_positions": [2, 3], + "read_positions": [2], + } + }, + ) + blocks = {b["id"]: b for b in rwset.block_rwsets(node, inout)} + assert "gamma" in blocks["B002"]["writes"] and "gamma" in blocks["B002"]["reads"] + + +def test_companion_externals_carry_the_siblings_inout_and_buffer_positions( + tmp_path: Path, +) -> None: + """An INOUT dummy and a caller-buffer OUT (``dy(*)``) are positions the + caller reads as well as writes; a plain OUT is written alone.""" + sibling = """\ +module blas_mod + use precision_mod, only: r8 => wp_r8 + implicit none +contains + subroutine dcopy(n, dx, incx, dy, incy) + integer, intent(in) :: n, incx, incy + real(r8), intent(in) :: dx(*) + real(r8), intent(out) :: dy(*) + dy(1) = dx(1) + end subroutine dcopy + subroutine dscal(n, da, dx, incx, flag) + integer, intent(in) :: n, incx + real(r8), intent(in) :: da + real(r8), intent(inout) :: dx(*) + integer, intent(out) :: flag + dx(1) = da * dx(1) + flag = 0 + end subroutine dscal +end module blas_mod +""" + record = interface.extract(_write(tmp_path, "blas.f90", sibling), kind_assumptions=KINDS) + table = interface.companion_externals(record) + # Upstream names every read position in ``read_positions`` and flags a + # caller-buffer OUT in ``buffer_positions``; what the lab spelled as a + # separate ``inout_positions`` is the written positions that are also read + # -- ``out_positions & read_positions``. A buffer OUT (``dy(*)``) and an + # INOUT dummy (``dx``) are read as well as written; a plain OUT is not. + assert table["dcopy"]["out_positions"] == [3] + assert table["dcopy"]["buffer_positions"] == [3] + assert 3 in table["dcopy"]["read_positions"] # the buffer OUT is read too + assert table["dscal"]["out_positions"] == [2, 4] + assert 2 in table["dscal"]["read_positions"] # the INOUT dummy is read too + assert 4 not in table["dscal"]["read_positions"] # a plain OUT is written alone + + def test_companion_externals_derive_from_the_siblings_record(tmp_path: Path) -> None: sibling = """\ module sib_mod @@ -1397,6 +1630,13 @@ def test_a_write_only_f77_dummy_is_given_the_intent_its_use_shows(tmp_path: Path intent(inout), because Fortran passes by reference and the update is the caller's to see. Without either attribute the return convention drops the dummy from the signature and from the return, and the value is lost. + + An array the body writes is intent(inout) -- the caller's buffer, handed + back -- and one only handed to a callee's ``intent(in)`` dummy is read + there, so it is intent(in) here. Left UNKNOWN, the differential gate + refused the whole subprogram: SLATEC's ``dqtcrt(a, zr, zi)`` declares no + intents, reads ``a`` through ``dcbcrt(a(2), ...)`` and writes the other + two, and lost its reference over that. """ from recast.fortran import interface @@ -1404,15 +1644,22 @@ def test_a_write_only_f77_dummy_is_given_the_intent_its_use_shows(tmp_path: Path module f77ish implicit none contains - subroutine rates(x, made, used, buf, n, onward) - real :: x, made, used, buf(4), onward + subroutine rates(x, made, used, buf, n, onward, given, filled, away) + real :: x, made, used, buf(4), onward, given(3), filled(3), away(2) integer :: n made = x * 2.0 used = x + used buf(1) = x n = 3 call elsewhere(onward) + call reads(given(2), filled) + call elsewhere_too(away) end subroutine rates + subroutine reads(v, w) + real, intent(in) :: v(*) + real, intent(out) :: w(3) + w = v(1) + end subroutine reads end module f77ish """ record = interface.extract(_write(tmp_path, "f77ish.f90", source), kind_assumptions=KINDS) @@ -1422,7 +1669,21 @@ def test_a_write_only_f77_dummy_is_given_the_intent_its_use_shows(tmp_path: Path assert intents["used"] == "INOUT" # read on its own right-hand side assert intents["x"] == "IN" # only read assert intents["buf"] == "INOUT" # one element written: the rest is the caller's (#23) - assert intents["onward"] == "UNKNOWN" # only passed on; its fate is the callee's + assert intents["onward"] == "UNKNOWN" # a scalar only passed on; its fate is the callee's + assert intents["given"] == "IN" # handed to a dummy the callee declares intent(in) + # Handed whole to a dummy the callee fully writes: OUT, not INOUT -- the + # array write-only rule (#23) is more precise than the read-only pass's + # conservative INOUT for a settled array, and it settles this one first. + assert intents["filled"] == "OUT" + # An array only *passed on* to a procedure this file does not describe + # stays UNKNOWN, the same as the scalar ``onward`` and as + # ``elsewhere_bound`` in ``test_an_array_dummy_without_intent...``: its + # fate is the callee's. Upstream's array-aware write-only pass settles the + # arrays this file *does* write (``filled`` above), which is what the + # lab's blanket "escaping array is INOUT" read-only rule stood in for + # before; with that precision in place, guessing INOUT for one handed off + # to an unseen callee would be a read/write the gate cannot stand behind. + assert intents["away"] == "UNKNOWN" # an array handed to a procedure with no body here def test_an_array_dummy_without_intent_is_given_the_intent_its_use_shows(tmp_path: Path) -> None: @@ -1762,6 +2023,52 @@ def test_an_associate_binds_its_aliases_and_analyses_its_body(tmp_path: Path) -> assert "cp" not in block["reads"] +def test_an_associate_that_only_reads_its_selector_reads_it(tmp_path: Path) -> None: + """numfor's ``csplint`` has an internal function whose whole use of the + host's ``csp`` is ``associate (A => csp%S(4, :))`` and a read of ``A``; + the selector counted as a write of ``csp``, and a function reference + cannot carry a host write back, so the block was refused (#49). The + alias is the selector: a body that only reads the alias reads the + variable, and the read-only pass can prove ``intent(in)``; one that + assigns the alias, or hands it to a dummy the callee writes, changes it, + through as many associations as the alias is passed through.""" + from recast.fortran import interface + + source = """\ +module assoc_intent + implicit none + type :: table + real :: s(4, 8) + end type table +contains + subroutine bump(v) + real, intent(inout) :: v(:) + v = v + 1.0 + end subroutine bump + function total(csp, i, seen, edited, handed, twice) result(y) + type(table), intent(in) :: csp + integer, intent(in) :: i + real :: seen(8), edited(8), handed(8), twice(8) + real :: y + associate (a => csp%s(4, :), b => seen, c => edited, d => handed, e => twice) + y = a(i) + b(i) + c(i) = y + call bump(d) + associate (f => e(1:2)) + f(1) = y + end associate + end associate + end function total +end module assoc_intent +""" + record = interface.extract(_write(tmp_path, "assoc_intent.f90", source), kind_assumptions=KINDS) + intents = {a["name"]: a["intent"] for a in record["subprograms"][1]["args"]} + assert intents["seen"] == "IN" # only read, through its alias + assert intents["edited"] != "IN" # assigned through its alias + assert intents["handed"] != "IN" # handed through its alias to an intent(inout) dummy + assert intents["twice"] != "IN" # assigned through an alias of its alias + + REBASED_COMPONENT = """\ module con_mod use precision_mod, only: r8 => wp_r8 @@ -2719,3 +3026,516 @@ def test_a_select_case_with_a_stopping_default_is_the_dummys_domain(tmp_path: Pa assert by_name["mode"]["domain"] == [1, 2] assert "domain" not in by_name["k"] assert "domain" not in by_name["x"] + + +def test_an_io_statement_reports_where_it_puts_what_it_read(tmp_path: Path) -> None: + """A READ writes its item list, INQUIRE writes its output specifiers, and + OPEN's NEWUNIT= writes the unit it allocated. The conservative fallback + called all three reads and no writes, which is the one direction this + analysis is not allowed to be wrong in -- and it disagreed with a + translation that makes the writes. + + An array item is a read of itself as well: its extent is what decides how + many values the statement consumes, and the translation spells that. + """ + from recast.fortran import rwset + + src = _write( + tmp_path, + "io.f90", + "module io_mod\ncontains\n" + " subroutine go(fname, row, n, ok)\n" + " character(len=*), intent(in) :: fname\n" + " real, intent(out) :: row(:)\n" + " integer, intent(out) :: n\n" + " logical, intent(out) :: ok\n" + " integer :: u, ios\n" + " open(newunit=u, file=fname, status='old')\n" + " inquire(unit=u, opened=ok)\n" + " read(u, *, iostat=ios) n\n" + " read(u, *) row\n" + " rewind(u)\n" + " close(u)\n" + " end subroutine go\n" + "end module io_mod\n", + ) + record = interface.extract(src, kind_assumptions=KINDS) + node = walk(parse(src), f03.Subroutine_Subprogram)[0] + blocks = rwset.block_rwsets(node, rwset.scope_for(record, "go")) + assert blocks[0] == {"id": "B001", "reads": ["fname"], "writes": ["u"]} + assert blocks[1] == {"id": "B002", "reads": ["u"], "writes": ["ok"]} + assert blocks[2] == {"id": "B003", "reads": ["u"], "writes": ["ios", "n"]} + assert blocks[3] == {"id": "B004", "reads": ["row", "u"], "writes": ["row"]} + assert blocks[4] == {"id": "B005", "reads": ["u"], "writes": []} + assert blocks[5] == {"id": "B006", "reads": ["u"], "writes": []} + + +def test_a_branch_specifier_is_control_flow_and_not_a_write(tmp_path: Path) -> None: + """``err=100`` names a statement label. Treating an output specifier as a + variable without asking what it is crashed the analysis on the first I/O + statement that took a branch.""" + from recast.fortran import rwset + + src = _write( + tmp_path, + "branch.f90", + "module b_mod\ncontains\n" + " subroutine go(u, x)\n" + " integer, intent(in) :: u\n" + " real, intent(out) :: x\n" + " read(u, *, err=100) x\n" + "100 continue\n" + " end subroutine go\n" + "end module b_mod\n", + ) + record = interface.extract(src, kind_assumptions=KINDS) + node = walk(parse(src), f03.Subroutine_Subprogram)[0] + blocks = rwset.block_rwsets(node, rwset.scope_for(record, "go")) + assert blocks[0] == {"id": "B001", "reads": ["u"], "writes": ["x"]} + + +def test_an_internal_write_reads_its_format_and_not_the_function_it_calls( + tmp_path: Path, +) -> None: + """``write(s, "(f0." // str_int(n) // ")") r`` reads ``n`` and ``r``. + + ``str_int`` is a call, which is control flow -- the same distinction + ``expr_reads`` draws everywhere else -- and the emitted line makes the + call without reading a variable of that name. Taking every remaining name + in the control list instead reported the callee as a read the translation + does not make, and failed the block on a format nobody could spell + differently. + """ + from recast.fortran import rwset + + src = _write( + tmp_path, + "fmt.f90", + "module fmt_mod\ncontains\n" + " pure function str_int(i) result(t)\n" + " integer, intent(in) :: i\n" + " character(len=8) :: t\n" + " write(t, '(i0)') i\n" + " end function str_int\n" + " pure function show(r, n, fmt) result(s)\n" + " real, intent(in) :: r\n" + " integer, intent(in) :: n\n" + " character(len=*), intent(in) :: fmt\n" + " character(len=32) :: s\n" + ' write(s, "(f0." // str_int(n) // ")") r\n' + " write(s, fmt) r\n" + " end function show\n" + "end module fmt_mod\n", + ) + record = interface.extract(src, kind_assumptions=KINDS) + node = walk(parse(src), f03.Function_Subprogram)[1] + blocks = rwset.block_rwsets(node, rwset.scope_for(record, "show")) + assert blocks[0] == {"id": "B001", "reads": ["n", "r"], "writes": ["s"]} + assert blocks[1] == {"id": "B002", "reads": ["fmt", "r"], "writes": ["s"]} + + +def test_an_allocatable_dummy_says_so(tmp_path: Path) -> None: + """An ALLOCATABLE dummy and an assumed-shape one are both spelled with + deferred upper bounds, and only the first is sized by the callee. Whoever + has to pass one -- the f2py wrapper -- cannot tell them apart without + this.""" + src = _write( + tmp_path, + "alloc.f90", + "module a_mod\ncontains\n" + " subroutine go(fresh, given)\n" + " real, allocatable, intent(out) :: fresh(:, :)\n" + " real, intent(in) :: given(:)\n" + " allocate(fresh(size(given), 1))\n" + " fresh = 0.0\n" + " end subroutine go\n" + "end module a_mod\n", + ) + record = interface.extract(src, kind_assumptions=KINDS) + args = {a["name"]: a for a in record["subprograms"][0]["args"]} + assert args["fresh"].get("allocatable") is True + assert "allocatable" not in args["given"] + + +# --- files a subprogram opens ------------------------------------------------ + +FILES = """\ +module files_mod + implicit none +contains + subroutine save_it(filename, d) + character(len=*), intent(in) :: filename + real(8), intent(in) :: d(:) + integer :: u + open(newunit=u, file=filename, status="replace") + write(u,*) d + write(*,*) size(d) + close(u) + end subroutine save_it + + subroutine load_it(filename, n) + character(len=*), intent(in) :: filename + integer, intent(out) :: n + integer :: u + open(newunit=u, file=filename, access="stream", status="old") + read(u,*) n + close(u) + end subroutine load_it + + subroutine complain(msg) + character(len=*), intent(in) :: msg + print *, msg + end subroutine complain +end module files_mod +""" + + +def test_a_character_dummy_an_open_names_is_marked_a_path(tmp_path: Path) -> None: + """``character(len=*) :: filename`` and ``character(len=*) :: msg`` are + declared identically; only the body tells a path from a message. What the + OPEN asks of the file is the other half: a path the subprogram *creates* + is a scratch name any caller may choose, and one it opens ``STATUS='OLD'`` + has to already hold something no caller wrote.""" + record = interface.extract(_write(tmp_path, "files.f90", FILES), kind_assumptions=KINDS) + subs = {s["name"]: s for s in record["subprograms"]} + assert subs["save_it"]["args"][0]["path"] == "created" + assert subs["load_it"]["args"][0]["path"] == "existing" + assert "path" not in subs["complain"]["args"][0] + assert "path" not in subs["save_it"]["args"][1], "only a character dummy names a file" + + +def test_the_unit_an_open_connects_is_recorded_for_the_writes_to_it(tmp_path: Path) -> None: + """A WRITE to a unit this body connected to a file puts records in that + file; a WRITE anywhere else is a log. Both sides of the read/write gate + have to draw that line in the same place, so the line is a fact the + interface record carries rather than one each side re-derives.""" + record = interface.extract(_write(tmp_path, "files.f90", FILES), kind_assumptions=KINDS) + subs = {s["name"]: s for s in record["subprograms"]} + assert subs["save_it"]["file_units"] == ["u"] + assert subs["complain"]["file_units"] == [] + + +def test_a_write_to_a_file_unit_reads_its_item_list(tmp_path: Path) -> None: + """The stub a log write becomes reads nothing, and the source side has to + say so too or every such block disagrees. A write that is translated does + read its items -- the record is built out of them.""" + from recast.fortran import rwset as rw + + record = interface.extract(_write(tmp_path, "files.f90", FILES), kind_assumptions=KINDS) + scope = rw.scope_for(record, "save_it") + node = _write(tmp_path, "files.f90", FILES) + unit = parse(node) + writes = walk(unit, f03.Write_Stmt) + to_file = rw.rwset(writes[0], scope) + assert to_file[0] == {"d", "u"} and to_file[1] == set() + assert rw.rwset(writes[1], scope) == (set(), set()), "write(*,...) is a log" + + +GUARDED = """\ +module guarded_mod + implicit none +contains + subroutine evaluate(xi, c, val) + real, intent(in) :: xi(:) + real, intent(in) :: c(0:,:) + real, intent(out) :: val + if (size(c,1) /= 5) call stop_error("size(c,1) /= 5") + if (size(c,2) /= size(xi)-1) call stop_error("size(c,2) /= size(xi)-1") + if (size(xi) < 2) call stop_error("too short") + if (size(c,1) /= 9) call stop_error("a second word on the same extent") + val = c(1,1) + xi(1) + end subroutine evaluate +end module guarded_mod +""" + + +def test_an_entry_check_on_a_shape_is_read_as_the_shape_it_requires(tmp_path: Path) -> None: + """``c(0:,:)`` is assumed-shape, so the interface says nothing about + either extent, and the two lines under it say the first is five and the + second one less than the length of ``xi``. A harness that has to *supply* + a shape has no other source for that, and every shape it invents is one + the subprogram stops on before computing anything. + + ``<`` bounds a range with no single answer in it and is not a guard; a + second guard on an extent already spoken for is not one either -- the + first is the one the body reaches.""" + record = interface.extract(_write(tmp_path, "guarded.f90", GUARDED)) + guards = record["subprograms"][0]["shape_guards"] + assert guards == [ + {"arg": "c", "axis": 0, "extent": "5"}, + {"arg": "c", "axis": 1, "extent": "size(xi,0) - 1"}, + ] + + +LIBRARY_MODULE = """\ +module libwrap_mod + implicit none + interface + function block_size(n) + integer :: block_size, n + end function block_size + subroutine solve_it(n, a) + integer :: n + real :: a(n) + end subroutine solve_it + end interface +contains + subroutine local_one(x) + real, intent(out) :: x + x = 1.0 + end subroutine local_one +end module libwrap_mod +""" + + +def test_a_procedure_a_sibling_only_declares_is_still_a_procedure(tmp_path: Path) -> None: + """The externals table is what tells a reader that ``nb = block_size(n)`` + is a call and not an array being subscripted. An interface module over a + compiled library declares its procedures and defines none, so a table + built from the definitions alone left every one of them looking like + data. No position is known to be written: an interface block carries no + INTENT, which is exactly what the translation can say about the call.""" + record = interface.extract(_write(tmp_path, "libwrap.f90", LIBRARY_MODULE)) + table = interface.companion_externals(record) + # An interface block carries no INTENT, so no position is known to be + # written: ``out_positions`` is empty (and with it the written-and-read + # subset the lab spelled as ``inout_positions``). What matters is that the + # declared-only names are in the table as procedures at all. + assert table["block_size"]["kind"] == "function" + assert table["block_size"]["out_positions"] == [] + assert table["solve_it"]["kind"] == "subroutine" + assert table["solve_it"]["out_positions"] == [] + assert table["local_one"]["out_positions"] == [0] + + +GENERIC_OVER_A_DECLARED_ONLY = """\ +module fft_wrap_mod + implicit none + interface + subroutine dcosti(n, wsave) + integer :: n + real :: wsave(*) + end subroutine dcosti + end interface + interface dct_t1i + procedure :: dcosti + end interface dct_t1i +contains + subroutine local_one(x) + real, intent(out) :: x + x = 1.0 + end subroutine local_one +end module fft_wrap_mod +""" + + +def test_a_generic_over_a_declared_only_specific_has_an_entry(tmp_path: Path) -> None: + """fftpack's wrapper module names bare subprograms through generics + (``dct_t1i`` over ``dcosti``), and defines none of them: each specific is + in the table by its interface declaration alone. The generic's entry is + built from that declaration, where a lookup among the definitions found + nothing and ended the whole run.""" + record = interface.extract(_write(tmp_path, "fft_wrap.f90", GENERIC_OVER_A_DECLARED_ONLY)) + table = interface.companion_externals(record) + assert table["dcosti"]["kind"] == "subroutine" + assert table["dct_t1i"]["kind"] == "subroutine" + assert table["dct_t1i"]["out_positions"] == [] + + +VALUE_GUARDED = """\ +module bounded_mod + implicit none +contains + subroutine solve(mode, weight, order, n) + integer, intent(in) :: mode(2) + real, intent(in) :: weight + integer, intent(in) :: order + integer, intent(inout) :: n + if (mode(1) < 1 .or. mode(1) > 2) call stop_error("mode /= 1 or 2") + if (weight < 0 .or. weight > 1) call stop_error("a real is not a mode") + if (order <= 0 .or. order >= 5) call stop_error("order outside 1..4") + if (n < 4) call stop_error("one-sided, so no range") + if (n < 1 .or. n > 3) n = 1 + end subroutine solve +end module bounded_mod +""" + + +def test_an_entry_check_on_a_value_is_read_as_the_range_it_requires(tmp_path: Path) -> None: + """``mode`` is a plain ``integer`` dummy, and nothing in the interface + says it selects between two modes -- the body's own first line does. A + harness drawing integers from a default range takes one draw in sixteen + for a pair of them, which is how a subprogram runs out of attempts having + compared nothing. + + ``<=``/``>=`` move the bound by one, a real is left alone (its range is + not a mode and the operator's table is where it belongs), and a one-sided + check says nothing about the other end, so it is not a range. Nor is a + check the body *answers*: ``if (n < 1 .or. n > 3) n = 1`` says the + subprogram takes any ``n``, and reading it as a range would narrow every + draw to the branch that does nothing.""" + record = interface.extract(_write(tmp_path, "bounded.f90", VALUE_GUARDED)) + assert record["subprograms"][0]["value_guards"] == [ + {"arg": "mode", "low": 1, "high": 2}, + {"arg": "order", "low": 1, "high": 4}, + ] + + +# --- what the polyroots corpus module taught the read/write analysis ---------- + + +def _rw(tmp_path: Path, name: str, src: str, sub_name: str) -> dict[str, dict[str, Any]]: + from recast.fortran import rwset + + path = _write(tmp_path, name, src) + record = interface.extract(path) + node = next( + s + for s in walk(parse(path), (f03.Subroutine_Subprogram, f03.Function_Subprogram)) + if str(walk(s, (f03.Subroutine_Stmt, f03.Function_Stmt))[0].children[1]).lower() == sub_name + ) + return {b["id"]: b for b in rwset.block_rwsets(node, rwset.scope_for(record, sub_name))} + + +SHADOWED_PARAMETER = """\ +module shadow_mod + implicit none + integer, parameter :: wp = kind(1.0d0) + real(wp), parameter :: pi = acos(-1.0_wp) +contains + subroutine cshape(opi, n, out) + integer, intent(in) :: n + real(wp), intent(in) :: opi(n) + real(wp), intent(out) :: out(n) + real(wp), dimension(n) :: pi + integer :: i + do i = 1, n + pi(i) = opi(i) + end do + out = pi + end subroutine cshape +end module shadow_mod +""" + + +def test_a_local_array_shadowing_a_module_parameter_is_still_written(tmp_path: Path) -> None: + """cpoly declares an array ``pi`` beside the module's constant ``pi``. + The module's rank-0 entry used to overwrite the local's, so ``pi(i) = + opi(i)`` matched the statement-function heuristic and the whole + assignment -- its write and its read -- was dropped from the block.""" + loop = _rw(tmp_path, "shadow.f90", SHADOWED_PARAMETER, "cshape")["B001"] + assert "pi" in loop["writes"] + assert "opi" in loop["reads"] + + +IMPLIED_DO = """\ +module fill_mod + implicit none + integer, parameter :: wp = kind(1.0d0) +contains + subroutine fill(deg, conv, alpha) + integer, intent(in) :: deg + integer, intent(out) :: conv(deg) + real(wp), intent(inout) :: alpha(deg+1) + integer :: i + conv = [(0, i=1,deg)] + alpha = [(alpha(i)*(3.8_wp*(i-1)+1),i=1,deg+1)] + end subroutine fill +end module fill_mod +""" + + +def test_an_implied_do_constructor_writes_its_counter_and_reads_its_bounds( + tmp_path: Path, +) -> None: + """``[(0, i=1,deg)]`` becomes ``[0 for i in range(1, deg + 1)]``: ``i`` is + bound by the constructor and ``deg`` is read. The generic descent read + ``i`` and nothing else, and every such block disagreed.""" + blocks = _rw(tmp_path, "fill.f90", IMPLIED_DO, "fill") + first, second = blocks["B001"], blocks["B002"] + assert first["writes"] == ["conv", "i"] and first["reads"] == ["deg"] + assert "i" in second["writes"] and "deg" in second["reads"] + assert "i" in second["reads"], "the value expression reads the counter" + + +HOST_VARS = """\ +module host_mod + implicit none + integer, parameter :: wp = kind(1.0d0) +contains + subroutine rot(x, y, z) + real(wp), intent(in) :: x + real(wp), intent(out) :: y, z + real(wp) :: a, b + a = x + b = 2.0_wp * x + call helper(y) + z = func(y) + contains + subroutine helper(r) + real(wp), intent(out) :: r + r = a + b + end subroutine helper + function func(q) result(res) + real(wp), intent(in) :: q + real(wp) :: res + res = q * a + end function func + end subroutine rot +end module host_mod +""" + + +def test_a_call_to_an_internal_procedure_reads_the_host_variables_it_uses( + tmp_path: Path, +) -> None: + """The translation passes ``helper``'s host variables as trailing actuals + (``host_vars``), so on that side the call reads ``a`` and ``b``; without + the same reads here every block of rpoly that called one of its helpers + disagreed. A function reference counts them the same way.""" + blocks = _rw(tmp_path, "host.f90", HOST_VARS, "rot") + call = blocks["B003"] + assert call["writes"] == ["y"] and call["reads"] == ["a", "b"] + reference = blocks["B004"] + assert reference["writes"] == ["z"] and reference["reads"] == ["a", "y"] + + +DECLARED_IN_A_SUBPROGRAM = """\ +module eig_mod + implicit none + integer, parameter :: wp = kind(1.0d0) +contains + subroutine eig(n, a, wr, wi, info) + integer, intent(in) :: n + real(wp), intent(in) :: a(n, n) + real(wp), intent(out) :: wr(n), wi(n) + integer, intent(out) :: info + real(wp), dimension(1) :: vl, vr + real(wp) :: work(3*n) + interface + subroutine xgeev(jobvl, jobvr, n, a, lda, wr, wi, vl, ldvl, vr, ldvr, work, lwork, info) + implicit none + character :: jobvl, jobvr + integer :: info, lda, ldvl, ldvr, lwork, n + double precision :: a(lda, *), vl(ldvl, *), vr(ldvr, *), wi(*), work(*), wr(*) + end subroutine xgeev + end interface + call xgeev('N', 'N', n, a, n, wr, wi, vl, 1, vr, 1, work, 3*n, info) + end subroutine eig +end module eig_mod +""" + + +def test_an_interface_declared_inside_a_subprogram_is_extracted(tmp_path: Path) -> None: + """polyroots declares LAPACK's ``dgeev`` in the subroutine that calls it. + Collected only from the module's specification part, the declaration was + invisible: the call was refused as an unknown external, and the reference + build had nothing to stub the undefined symbol with.""" + record = interface.extract(_write(tmp_path, "eig.f90", DECLARED_IN_A_SUBPROGRAM)) + declared = record["interfaces"]["xgeev"] + assert declared["kind"] == "subroutine" + assert [a["name"] for a in declared["args"]][:3] == ["jobvl", "jobvr", "n"] + assert declared["args"][7]["dims"][-1].get("assumed_size"), "vl(ldvl, *)" + assert "xgeev" not in {s["name"] for s in record["subprograms"]}, "declared, not defined" + eig = next(s for s in record["subprograms"] if s["name"] == "eig") + assert "xgeev" in eig["external_calls"], "still an external for the reference build" diff --git a/tests/test_fortran_companions.py b/tests/test_fortran_companions.py index e229c18..16b6452 100644 --- a/tests/test_fortran_companions.py +++ b/tests/test_fortran_companions.py @@ -330,6 +330,25 @@ def test_a_module_that_answers_for_its_only_list_is_not_walked_past(reexport_tre assert {c["module"] for c in facts.provenance["companions"]} == {"helper"} +def test_what_a_companion_uses_is_named_for_the_build_but_not_put_in_scope( + reexport_tree: Path, +) -> None: + """``direct`` cannot see ``mykinds`` -- ``helper`` answers for its own + only-list, so the walk stops there -- but a reference build compiles + ``helper.f90`` from source, and gfortran cannot read ``use mykinds`` + without ``mykinds.mod``. The closure the compiler needs is recorded + beside the companions rather than among them: a name here must not + resolve through a module this unit cannot name.""" + (reexport_tree / "direct.f90").write_text(DIRECT) + frontend = FortranFrontend() + unit = next(u for u in frontend.discover(reexport_tree) if u.uid == "fortran:direct") + provenance = frontend.analyze(unit, reexport_tree).provenance + dependencies = {c["module"]: c for c in provenance["companion_dependencies"]} + assert set(dependencies) == {"mykinds"} + assert dependencies["mykinds"]["source"] == "mykinds.f90" + assert "mykinds" not in {c["module"] for c in provenance["companions"]} + + ELSEWHERE = """\ module needs_outside use mykinds, only: wp @@ -423,3 +442,119 @@ def test_a_slice_origin_is_spelled_through_the_use_bindings(tmp_path: Path) -> N body = next(line for line in text.splitlines() if line.strip().startswith("v[")) assert "_elsewhere_mod.lo" in body, body assert "(1 - lo)" not in body, body + + +def test_the_candidate_carries_the_companion_it_calls(tree: Path) -> None: + """A resolved companion is a sibling *source*, not a deployed module. + + The header imports ``helper_numpy``, and ``differential.bitexact`` stages + a candidate's own files and nothing else -- so a candidate that names a + file it does not carry raises ``ModuleNotFoundError`` before a number is + compared. The sibling's translation rides along; ``mykinds``, which binds + no alias, has its import dropped instead of a file nothing reads. + """ + import re + + from recast.model import Unit + from recast.transform.numpy.translate import NumpyTranslation + + unit = Unit(uid="fortran:main_mod", kind="module", sources=(Path("main_mod.f90"),)) + candidate = NumpyTranslation().apply(unit, _facts(tree), {"root": str(tree)}) + + carried = {p.name for p in candidate.files} + assert "helper_numpy.py" in carried + assert "mykinds_numpy.py" not in carried + assert candidate.notes["bundled"] == ["helper"] + + # Nothing the candidate imports is missing from the candidate. + for content in candidate.files.values(): + for module in re.findall(r"^import (\w+_numpy)", content.decode(), re.MULTILINE): + assert f"{module}.py" in carried, module + + +def test_a_companion_the_operator_deploys_is_left_alone(tree: Path) -> None: + """``module_py`` is the operator saying where the sibling's translation + already lives. Overwriting it with one of ours would put a second + spelling of the same module in the candidate.""" + from recast.model import Unit + from recast.transform.numpy.translate import NumpyTranslation + + facts = _facts(tree) + declared = [ + {**c, "alias": "_helper", "module_py": "deployed_helper"} + for c in facts.provenance["companions"] + if c["module"] == "helper" + ] + unit = Unit(uid="fortran:main_mod", kind="module", sources=(Path("main_mod.f90"),)) + candidate = NumpyTranslation().apply(unit, facts, {"root": str(tree), "companions": declared}) + text = candidate.files[Path("main_mod_numpy.py")].decode() + assert "import deployed_helper as _helper" in text + assert "bundled" not in candidate.notes + assert {p.name for p in candidate.files} == {"main_mod_numpy.py", "main_mod_constants.py"} + + +LIBRARY_INTERFACE = """\ +module libwrap + use mykinds, only: wp + implicit none + interface + subroutine solve_it(n, a, info) + import :: wp + integer :: n, info + real(wp) :: a(n) + end subroutine solve_it + end interface +end module libwrap +""" + +CALLS_LIBRARY = """\ +module uses_library + use mykinds, only: wp + use libwrap, only: solve_it + implicit none +contains + subroutine go(a, n) + real(wp), intent(inout) :: a(:) + integer, intent(in) :: n + integer :: info + call solve_it(n, a, info) + end subroutine go +end module uses_library +""" + + +def test_a_call_into_a_companions_interface_block_is_spelled_through_it(tmp_path: Path) -> None: + """``use lapack, only: dgesv`` names a module whose whole content is + interface blocks -- the bodies are in a library the original program + linked. The name is in scope exactly as a module procedure would be, and + the interface is the whole statement of what calling it means, so the call + is bound against it and spelled through the sibling's alias. Refusing it + instead left the module's every automatic array and every other block + deferred behind one name.""" + from recast.transform.numpy.translate import NumpyTranslation + + (tmp_path / "mykinds.f90").write_text(KINDS) + (tmp_path / "libwrap.f90").write_text(LIBRARY_INTERFACE) + (tmp_path / "uses_library.f90").write_text(CALLS_LIBRARY) + frontend = FortranFrontend() + unit = next(u for u in frontend.discover(tmp_path) if u.uid == "fortran:uses_library") + facts = frontend.analyze(unit, tmp_path) + candidate = NumpyTranslation().apply(unit, facts, {"root": str(tmp_path)}) + text = candidate.files[Path("uses_library_numpy.py")].decode() + assert "_libwrap.solve_it(n, a, info)" in text + assert "import libwrap_numpy as _libwrap" in text + assert candidate.deferred == [] + + +def test_a_body_reports_the_names_it_reaches_outside_its_own_module(tmp_path: Path) -> None: + """``calls`` records only this module's own procedures, so nothing in the + record said ``go`` reaches ``solve_it`` -- and a consumer that has to know + which subprograms depend on a library the build does not have (the f2py + oracle) had no way to ask.""" + (tmp_path / "mykinds.f90").write_text(KINDS) + (tmp_path / "libwrap.f90").write_text(LIBRARY_INTERFACE) + (tmp_path / "uses_library.f90").write_text(CALLS_LIBRARY) + frontend = FortranFrontend() + unit = next(u for u in frontend.discover(tmp_path) if u.uid == "fortran:uses_library") + record = frontend.analyze(unit, tmp_path).interface + assert record["subprograms"][0]["external_calls"] == ["solve_it"] diff --git a/tests/test_fortran_semantics.py b/tests/test_fortran_semantics.py index e726580..886e0ba 100644 --- a/tests/test_fortran_semantics.py +++ b/tests/test_fortran_semantics.py @@ -293,3 +293,59 @@ def test_a_generic_call_that_matches_nothing_refuses(sem) -> None: def test_a_name_that_is_not_generic_refuses(sem) -> None: with pytest.raises(semantics.AmbiguousDispatch, match="not a generic"): sem.dispatch("scale_scalar", []) + + +def test_a_complex_overload_does_not_match_a_real_actual(tmp_path: Path) -> None: + """``interface.dtype_of`` has no dtype for COMPLEX and spells it + ``UNKNOWN(COMPLEX)``. Read as undecided, that marker matched anything, and + the corpus's ``call sortpairs(len2, vecs)`` -- two reals -- was ambiguous + between the real-vector and the complex-vector overload, which deferred + the only call site in the subprogram. + + Unreduced is not undecided: no real actual reaches a complex dummy. + """ + src = tmp_path / "pairs.f90" + src.write_text( + "module pairs_mod\n" + " implicit none\n" + " interface sortpairs\n" + " module procedure real_vecs, complex_vecs\n" + " end interface sortpairs\n" + "contains\n" + " subroutine drive(nums, vecs)\n" + " real(8), intent(inout) :: nums(:)\n" + " real(8), intent(inout) :: vecs(:,:)\n" + " call sortpairs(nums, vecs)\n" + " end subroutine drive\n" + " subroutine real_vecs(nums, vecs)\n" + " real(8), intent(inout) :: nums(:)\n" + " real(8), intent(inout) :: vecs(:,:)\n" + " end subroutine real_vecs\n" + " subroutine complex_vecs(nums, vecs)\n" + " real(8), intent(inout) :: nums(:)\n" + " complex(8), intent(inout) :: vecs(:,:)\n" + " end subroutine complex_vecs\n" + "end module pairs_mod\n" + ) + record = interface.extract(src, kind_assumptions=KINDS) + sem = semantics.for_subprogram(record, "drive") + sub = next( + s + for s in walk(parse(src), f03.Subroutine_Subprogram) + if str(walk(s, f03.Subroutine_Stmt)[0].children[1]).lower() == "drive" + ) + call = walk(sub, f03.Call_Stmt)[0] + assert sem.dispatch("sortpairs", list(call.children[1].children)) == "real_vecs" + + +def test_double_complex_is_complex_and_not_a_double(tmp_path: Path) -> None: + """``DOUBLE COMPLEX`` starts with DOUBLE and was answered ``float64``, + which now that COMPLEX rules an overload out would offer a complex actual + to a real dummy -- the silent wrong pick dispatch refuses to make. + + Upstream now gives a complex a concrete dtype (``complex128``) rather than + the ``UNKNOWN(COMPLEX)`` marker the lab used before COMPLEX had one; what + this still checks is that DOUBLE COMPLEX reads as *complex*, not as the + float64 that ``startswith('DOUBLE')`` would otherwise hand back.""" + assert interface.dtype_of("DOUBLE COMPLEX", None, {}) == "complex128" + assert interface.dtype_of("DOUBLE PRECISION", None, {}) == "float64" diff --git a/tests/test_numpy_modules.py b/tests/test_numpy_modules.py index f64882e..b960411 100644 --- a/tests/test_numpy_modules.py +++ b/tests/test_numpy_modules.py @@ -187,3 +187,64 @@ def test_py_lines_point_at_the_markers_of_the_finished_file( continue assert lines[first - 1].startswith(f" # {entry['block']} <- ") assert last >= first + + +# --- procedures declared here and defined nowhere ---------------------------- + + +INTERFACE_ONLY = """\ +module libwrap + implicit none + interface + subroutine dgesv(n, nrhs, a, lda, ipiv, b, ldb, info) + integer :: info, lda, ldb, n, nrhs + integer :: ipiv(*) + double precision :: a(lda,*), b(ldb,*) + end subroutine + subroutine dsyevd(jobz, uplo, n, a, lda, w, info) + character :: jobz, uplo + integer :: info, lda, n + double precision :: a(lda,*), w(*) + end subroutine + subroutine touch(x) + double precision :: x + end subroutine + end interface +end module libwrap +""" + + +@pytest.fixture(scope="module") +def libwrap(tmp_path_factory: pytest.TempPathFactory) -> Path: + path = tmp_path_factory.mktemp("libwrap") / "libwrap.f90" + path.write_text(INTERFACE_ONLY) + return path + + +def test_a_declared_procedure_recast_can_supply_is_defined_here( + libwrap: Path, source: Path +) -> None: + """``use lapack, only: dgesv`` binds an interface-only declaration like a + module procedure, so a caller is emitted as ``_lapack.dgesv(...)`` -- into + a file that had nothing of the name in it, because the bodies are in a + library this build does not link. ``recast.references`` supplies one, and + the reference build compiles its Fortran twin, so the call means the same + thing on both sides. + + Only the names it has an implementation for: ``dsyevd`` is still + declared, still undefined, and still disclaims its callers in the oracle. + ``touch`` is defined by a sibling in this build, so nothing is missing. + """ + renderer = Modules( + subprograms=Subprograms( + record=interface.extract(libwrap, kind_assumptions=KINDS), + constants=constants.extract(libwrap), + profile=PROFILES["gfortran"], + companions=(interface.extract(source, kind_assumptions=KINDS),), + ), + ) + text, _ = renderer.render(libwrap) + compile(text, "libwrap_numpy.py", "exec") + assert "\ndef dgesv(" in text + assert "\ndef dsyevd(" not in text + assert "\ndef touch(" not in text diff --git a/tests/test_numpy_runtime.py b/tests/test_numpy_runtime.py index f473305..d5999bb 100644 --- a/tests/test_numpy_runtime.py +++ b/tests/test_numpy_runtime.py @@ -14,6 +14,7 @@ import ast import math +from typing import Any import pytest @@ -214,31 +215,37 @@ def test_sqrt_of_a_non_negative_is_the_hardware_root_to_the_bit() -> None: assert math.copysign(1.0, runtime._f_sqrt(-0.0)) == -1.0 -def test_min_and_max_absorb_a_nan_on_the_left_and_propagate_one_on_the_right() -> None: - """Not a tidy rule, and not a choice: it is what gfortran's SSE ``minsd`` - fold does, measured. ``min(NaN, 0)`` is 0 and ``min(0, NaN)`` is NaN. - - Python's builtin ``min`` returns its first argument on a NaN, which is the - opposite of this on one side and the same on the other -- so a translation - using it agrees on half the cases and silently disagrees on the rest. +def test_min_and_max_absorb_a_nan_operand_wherever_it_falls() -> None: + """Measured against the f2py-built reference, not a standalone toy: + gfortran's MIN/MAX at the golden ``-O1 -fno-fast-math`` flags absorb a + NaN operand in *either* position -- ``min(NaN, x)`` and ``min(x, NaN)`` + are both ``x`` -- and yield NaN only when every operand is NaN. This is + ``fmin``/``fmax`` order. A ``quadpack`` body reaching ``min(1.0_wp, x)`` + with ``x`` gone NaN keeps the 1.0, so a "propagate on the right" model + would mismatch the reference (``dqk15i`` did). Python's builtin ``min`` + returns its first argument on a NaN, a different trap again. """ assert runtime._f_min(np.nan, 1.0) == 1.0 - assert np.isnan(runtime._f_min(1.0, np.nan)) + assert runtime._f_min(1.0, np.nan) == 1.0 assert runtime._f_max(np.nan, 1.0) == 1.0 - assert np.isnan(runtime._f_max(1.0, np.nan)) + assert runtime._f_max(1.0, np.nan) == 1.0 + assert np.isnan(runtime._f_min(np.nan, np.nan)) + assert np.isnan(runtime._f_max(np.nan, np.nan)) assert runtime._f_min(2.0, 1.0) == 1.0 + assert runtime._f_max(2.0, 1.0) == 2.0 -def test_the_vectorised_min_and_max_keep_the_same_asymmetry() -> None: +def test_the_vectorised_min_and_max_keep_the_same_nan_absorption() -> None: """Elementwise, and each element behaves like the scalar fold -- otherwise a loop and its vectorised form would disagree on NaN alone.""" a = np.array([np.nan, 2.0, 3.0]) b = np.array([1.0, np.nan, 1.0]) - vector = list(runtime._f_vmin(a, b)) - scalar = [runtime._f_min(x, y) for x, y in zip(a, b, strict=True)] - assert vector[0] == scalar[0] == 1.0 - assert np.isnan(vector[1]) and np.isnan(scalar[1]) - assert vector[2] == scalar[2] == 1.0 + vmin = list(runtime._f_vmin(a, b)) + vmax = list(runtime._f_vmax(a, b)) + scalar_min = [runtime._f_min(x, y) for x, y in zip(a, b, strict=True)] + scalar_max = [runtime._f_max(x, y) for x, y in zip(a, b, strict=True)] + assert vmin == scalar_min == [1.0, 2.0, 1.0] + assert vmax == scalar_max == [1.0, 2.0, 3.0] def test_strict_libm_matches_the_c_library_elementwise() -> None: @@ -332,12 +339,28 @@ def test_a_derived_type_local_is_an_attribute_container() -> None: assert obj.q[0] == 0.0, "copying a derived type copies its components" -def test_list_directed_write_starts_with_a_blank_and_pads_to_width() -> None: - """The output of this is compared against gfortran's byte for byte, so its - leading blank and column widths are the answer, not formatting taste.""" - record = runtime._f_list_write(np.int32(42)) - assert record.startswith(" ") - assert record == " " + "42".rjust(12) + " " +def test_list_directed_write_uses_gfortrans_own_column_widths() -> None: + """The output of this is compared against gfortran's byte for byte, so the + column widths are the answer, not formatting taste. Every string below was + read off gfortran's own ``write(u,*)``. + + The leading blank a list-directed record starts with is the first field's + padding rather than a separate prefix. Prepending one as well put every + record a column out -- invisible until a subprogram whose only product is + the file it writes was compared against the compiler. + """ + assert runtime._f_list_write(np.int32(5)) == " 5" + assert runtime._f_list_write(np.int32(-12345)) == " -12345" + assert runtime._f_list_write("abc") == " abc" + assert runtime._f_list_write("abc", np.int32(7)) == " abc 7" + assert runtime._f_list_write(np.bool_(True), np.bool_(False)) == " T F" + # A real(8) is a G25.17E3 field and the blank that follows it: seventeen + # significant figures, and zero counts as one digit before the point. + assert runtime._f_list_write(0.0) == " 0.0000000000000000 " + assert runtime._f_list_write(0.5) == " 0.50000000000000000 " + assert runtime._f_list_write(10.5) == " 10.500000000000000 " + assert runtime._f_list_write(-1.0e-12) == " -9.9999999999999998E-013" + assert runtime._f_list_write(1.0, np.int32(2)) == " 1.0000000000000000 2" # --- copy-out ---------------------------------------------------------------- @@ -395,3 +418,282 @@ def test_sum_accumulates_in_fortran_element_order() -> None: along = along + a[i, :] assert np.array_equal(runtime._f_vsum(a, axis=0), along) assert runtime._f_vsum(np.array([1, 2, 3], dtype=np.int32)) == 6 + + +# --- external files ---------------------------------------------------------- + + +def test_a_list_directed_read_takes_the_values_the_item_list_asks_for( + tmp_path: Any, +) -> None: + """One record per READ, however many records the values are spread over, + and the rest of the last record discarded -- which is what makes counting + the rows of a file by reading one value per record work.""" + path = tmp_path / "grid.txt" + path.write_text("1 2 3\n4 5 6\n") + _, unit = runtime._f_open(None, str(path), status="old") + ios, first = runtime._f_read(unit, None, [("float64", None, None)]) + assert (ios, first) == (0, 1.0), "the rest of the record is discarded" + ios, row = runtime._f_read(unit, None, [("float64", 3, None)]) + assert ios == 0 and list(row) == [4.0, 5.0, 6.0] + ios, _ = runtime._f_read(unit, None, [("float64", None, None)], strict=False) + assert ios == -1, "end of file is IOSTAT_END, not an exception, when asked for" + runtime._f_close(unit) + + +def test_a_non_advancing_read_stops_at_the_end_of_its_record(tmp_path: Any) -> None: + """``read(u, '(a)', advance='no')`` walks one record a character at a + time and reports IOSTAT_EOR at its end. Counting the columns of a text + file is written this way, and a shim that ran on into the next record + would count every column in the file.""" + path = tmp_path / "row.txt" + path.write_text("ab\ncd\n") + _, unit = runtime._f_open(None, str(path), status="old") + read = [] + while True: + ios, char = runtime._f_read(unit, "(a)", [("str", None, 1)], advance="no", strict=False) + if ios != 0: + break + read.append(char) + assert read == ["a", "b"] and ios == -2 + ios, char = runtime._f_read(unit, "(a)", [("str", None, 1)], advance="no", strict=False) + assert (ios, char) == (0, "c"), "EOR left the file positioned at the next record" + runtime._f_rewind(unit) + ios, char = runtime._f_read(unit, "(a)", [("str", None, 1)], advance="no", strict=False) + assert (ios, char) == (0, "a") + runtime._f_close(unit) + + +def test_inquire_answers_for_the_units_that_are_connected(tmp_path: Any) -> None: + """``inquire(unit=n, opened=inuse)`` is how a program picks a free unit, + so NEWUNIT= hands out negative numbers the way gfortran does and leaves + every number such a scan walks free.""" + path = tmp_path / "f.txt" + path.write_text("x\n") + _, unit = runtime._f_open(None, str(path), status="old") + assert int(unit) < 0 + assert runtime._f_inquire(unit, None, "opened") is True + assert runtime._f_inquire(10, None, "opened") is False + assert runtime._f_inquire(6, None, "opened") is True, "stdout is preconnected" + assert runtime._f_inquire(None, str(path), "exist") is True + assert runtime._f_inquire(None, str(tmp_path / "no.txt"), "exist") is False + runtime._f_close(unit) + assert runtime._f_inquire(unit, None, "opened") is False + + +def test_an_open_that_cannot_connect_raises_unless_iostat_was_asked_for( + tmp_path: Any, +) -> None: + """A statement without IOSTAT= aborts the program in Fortran; one with it + carries on with the status in a variable.""" + missing = str(tmp_path / "absent.txt") + with pytest.raises(OSError, match="does not exist"): + runtime._f_open(None, missing, status="old") + ios, _ = runtime._f_open(None, missing, status="old", strict=False) + assert int(ios) == 2 + + +def test_a_zero_width_field_is_as_wide_as_its_value(tmp_path: Any) -> None: + """``(i0)`` and ``(f0.6)`` are how the corpus converts a number to a + string. A zero width is not an overflow: it asks for the shortest field + the value fits in, and treating it as one wrote asterisks -- or, worse, + nothing at all.""" + assert runtime._f_fmt_write("(i0)", np.int32(42)) == "42" + assert runtime._f_fmt_write("(f0.6)", 1.5) == "1.500000" + + +def test_a_stream_connection_is_unformatted_and_pos_counts_bytes(tmp_path: Any) -> None: + """``access='stream'`` with no FORM= is an UNFORMATTED connection -- + gfortran reports it as one -- and it is how a program reads a file byte + by byte. A PPM is the case: the header is read as text through one + connection, INQUIRE(POS=) says where it ended, and the pixels come back + through a second one positioned there with POS=. Reading those bytes as + text records takes a pixel of value 10 for the end of a record. + """ + path = tmp_path / "img.ppm" + path.write_bytes(b"P6\n2 1\n255\n" + bytes([7, 8, 9, 250, 251, 252]) + b"\n") + + _, unit = runtime._f_open(None, str(path), access="stream", form="formatted", status="old") + ios, signature = runtime._f_read(unit, "(a2)", [("str", None, 2)]) + assert (int(ios), signature) == (0, "P6") + _, w, h = runtime._f_read(unit, None, [("int32", None, None), ("int32", None, None)]) + _, ncol = runtime._f_read(unit, None, [("int32", None, None)]) + assert (int(w), int(h), int(ncol)) == (2, 1, 255) + offset = runtime._f_inquire(unit, None, "pos") + assert int(offset) == 12, "the byte after the header, counted from one" + runtime._f_close(unit) + + _, unit = runtime._f_open(None, str(path), access="stream", status="old") + assert runtime._f_inquire(unit, None, "form") == "UNFORMATTED" + ios, ccode = runtime._f_read(unit, None, [("str", None, 1)], pos=int(offset) - 1) + assert (int(ios), ccode) == (0, "\n"), "POS= is where the read starts, not where it ends" + pixels = [ord(runtime._f_read(unit, None, [("str", None, 1)])[1]) for _ in range(6)] + assert pixels == [7, 8, 9, 250, 251, 252] + ios, _ = runtime._f_read(unit, None, [("str", None, 1)], strict=False) + assert int(ios) == 0, "the record terminator the file ends with" + ios, _ = runtime._f_read(unit, None, [("str", None, 1)], strict=False) + assert int(ios) == -1, "a short read is end of file" + runtime._f_close(unit) + + +def test_an_unformatted_sequential_read_is_still_refused(tmp_path: Any) -> None: + """A stream has no records to guess at; an unformatted *sequential* file + is wrapped in length markers only its compiler can spell, and reading it + here would put wrong numbers in the right variables.""" + path = tmp_path / "raw.dat" + path.write_bytes(b"\x04\x00\x00\x00") + _, unit = runtime._f_open(None, str(path), form="unformatted", status="old") + with pytest.raises(OSError, match="unformatted unit"): + runtime._f_read(unit, None, [("int32", None, None)]) + runtime._f_close(unit) + + +def test_a_format_shorter_than_its_item_list_reverts_and_ends_the_record() -> None: + """``write(u, '(3a1)') achar(pixel)`` on more than three components writes + more than one record: Fortran reverts to the start of the format and, in + doing so, ends the record. Stopping at the first pass instead dropped + every value after the third.""" + assert runtime._f_fmt_records("(3a1)", list("abcdefgh")) == ["abc", "def", "gh"] + assert runtime._f_fmt_records("(i0,' ',i0)", [8, 8]) == ["8 8"] + assert runtime._f_fmt_records("(a2)", ["P6"]) == ["P6"] + # ``/`` ends a record the same way, and a data descriptor with no value + # left ends the transfer where it stands. + assert runtime._f_fmt_records("(i0,/,i0)", [1, 2]) == ["1", "2"] + assert runtime._f_fmt_records("(i0,i0)", [1]) == ["1"] + + +def test_an_external_write_puts_its_records_in_the_file(tmp_path: Any) -> None: + """The bytes below are what gfortran's own ``saveppm`` writes for a + three-component pixel: a header, then non-advancing pixel writes that + continue one record, and the record terminator CLOSE puts on the + incomplete record the last of them left open.""" + path = tmp_path / "out.ppm" + _, unit = runtime._f_open(None, str(path), status="replace") + runtime._f_write(unit, "(a2)", ["P6"]) + runtime._f_write(unit, "(i0,' ',i0)", [2, 1]) + runtime._f_write(unit, "(i0)", [255]) + for pixel in ([1, 2, 3], [4, 5, 6]): + runtime._f_write(unit, "(3a1)", [runtime._f_vachar(np.array(pixel))], advance="no") + runtime._f_close(unit) + assert path.read_bytes() == b"P6\n2 1\n255\n" + bytes([1, 2, 3, 4, 5, 6]) + b"\n" + + +def test_a_write_to_a_unit_nothing_connected_is_a_log(capsys: Any, tmp_path: Any) -> None: + """A unit no OPEN in the translation connected is the log destination it + always was: the records go where PRINT's go, and no file is invented for + a connection this translation does not hold.""" + runtime._f_write(6, None, ["hello"]) + assert capsys.readouterr().out == " hello\n" + assert not list(tmp_path.iterdir()) + + +def test_achar_over_an_array_is_one_character_per_element() -> None: + """A fixed-width NumPy string array would pad every element; the item list + a WRITE formats one value at a time must not be padded.""" + rendered = runtime._f_vachar(np.array([[65, 10], [13, 250]], dtype=np.int32)) + assert rendered.tolist() == [["A", "\n"], ["\r", "\xfa"]] + + +def test_log_outside_its_domain_is_an_ieee_value_not_an_exception() -> None: + """``math.log`` raises where the compiled reference carries on with + ``-Infinity`` and ``NaN``. Raising turns a number both sides agree on into + "the candidate raised", which the differential gate reports as no + comparison at all.""" + assert runtime._f_log(math.e) == 1.0 + assert runtime._f_log(0.0) == float("-inf") + assert math.isnan(runtime._f_log(-1.0)) + assert runtime._f_log10(100.0) == 2.0 + assert math.isnan(runtime._f_log10(-1.0)) + + +def test_int_of_a_value_no_integer_holds_is_the_conversion_the_compiler_emits() -> None: + """Python's ``int`` raises on a NaN and grows without bound past the range + of an INTEGER; gfortran emits the hardware conversion, which answers every + value it cannot represent with the most negative integer of the kind.""" + assert runtime._f_int(2.9) == 2 + assert runtime._f_int(-2.9) == -2 + assert runtime._f_int(float("nan")) == -(2**31) + assert runtime._f_int(1e30) == -(2**31) + assert runtime._f_int(1e30, 8) == -(2**63) + assert runtime._f_int(7) == 7 + + +@pytest.mark.parametrize("exponent", [0, 1, 2, 3, 4, 5, 7, 12, -1, -3]) +def test_a_runtime_integer_power_is_the_expansion_a_literal_one_gets(exponent: int) -> None: + """``(xe(i)-x0)**(j-1)`` has no literal for ``expand_power`` to expand, so + what was left was Python's ``**`` -- a libm ``pow`` call gfortran never + makes for an integer exponent. libgcc squares and multiplies, LSB first, + which is exactly what ``expand_power`` writes out when it can, so the two + routes have to reach the same bits or a subprogram is bit-exact or not + depending on whether its exponent happened to be a literal.""" + from recast.transform.numpy.expressions import expand_power + + for x in (0.31672977626795387, -3.25, 1.0000000001, 7.5e-8): + spelled = eval(expand_power("x", exponent), {"x": x}) if exponent else 1.0 + assert runtime._f_powi(x, exponent) == spelled, f"{x}**{exponent}" + + +def test_a_runtime_integer_power_is_not_the_pow_call_python_would_make() -> None: + """The point of the helper, stated as the difference it exists for: one + ULP, on an ordinary value, in a direction nothing structural can see.""" + x = 0.31672977626795387 + assert runtime._f_powi(x, 3) != x**3 + assert runtime._f_powi(x, 3) == x * (x * x) + + +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.""" + 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) + assert folded[:, 0].tolist() == [-1.0, 3.0] + assert 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: + """``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``).""" + 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[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 + + +def test_seq_tail_out_reaches_a_c_ordered_matrix() -> None: + """Where the actual is not Fortran-contiguous and no slice spells the + tail, it was a copy, and the callee's writes reach the caller only + through the write-back: the values land at the same column-major + positions, and nothing is written for an empty result.""" + c_ordered = np.arange(1.0, 13.0).reshape(3, 4) + tail = runtime._f_seq_tail(c_ordered, 4) # (2, 2) onward, a copy + assert not np.shares_memory(tail, c_ordered) + tail[:] = -tail + runtime._f_seq_tail_out(c_ordered, 4, tail) + expected = np.arange(1.0, 13.0).reshape(3, 4) + flat = expected.ravel(order="F") + flat[4:] = -flat[4:] + assert np.array_equal(c_ordered, flat.reshape(3, 4, order="F")) + runtime._f_seq_tail_out(c_ordered, 4, np.zeros(0)) + assert np.array_equal(c_ordered, flat.reshape(3, 4, order="F")) diff --git a/tests/test_numpy_statements.py b/tests/test_numpy_statements.py index 4189ad6..6f3b64d 100644 --- a/tests/test_numpy_statements.py +++ b/tests/test_numpy_statements.py @@ -119,12 +119,13 @@ allocate(off(2:n)) end subroutine alloc - subroutine calls(a, s, t, c, n, j, w, flat) + subroutine calls(a, s, t, c, n, j, w, flat, m2) real(r8), intent(inout) :: a(10), c(10) real(r8), intent(inout) :: s, t integer, intent(in) :: n, j real(r8), intent(in) :: w(4, 3) real(r8), intent(in) :: flat(8) + real(r8), intent(inout) :: m2(4, 3) call scale_it(a, s) call helper(s, c(1)) call helper(s, c(1), extra=t) @@ -143,6 +144,10 @@ call tailv(a(n)) call tailm(a(n)) s = tail_norm(a(n)) + call tailv(m2(2, j)) + call tailm(m2(2, j)) + call tailv(m2) + s = tail_norm(m2(2, j)) end subroutine calls subroutine initialised(x) @@ -227,9 +232,15 @@ real(r8), intent(inout) :: a(10) integer, intent(out) :: ios character(len=32) :: line + character :: ccode + integer :: u, offset + open(newunit=u, file='out.dat', status='replace') write(*,*) s write(line,*) s, a(1) write(11,*,iostat=ios) s + read(u, pos=offset-1) ccode + write(u, '(a1)', advance='no') ccode + write(line, '(a1)', advance='no') ccode stop 'boom' return end subroutine io @@ -373,14 +384,60 @@ j = tab(1) end subroutine seeded - subroutine io_edges(u, ok) + subroutine io_edges(u, ok, x, name) integer, intent(in) :: u logical, intent(out) :: ok + real(r8), intent(out) :: x + character(len=*), intent(in) :: name + integer :: ios, u2 + character(len=20) :: enc + open(newunit=u2, file=name, status='old') rewind(u) backspace(u) inquire(unit=u, opened=ok) + read(u, *, iostat=ios) x + print *, x + close(u) + inquire(unit=u, encoding=enc) error stop 'nothing to do' end subroutine io_edges + subroutine pseudorank(a, n, tau, k, kp1) + real(r8), intent(in) :: a(n, n) + integer, intent(in) :: n + real(r8), intent(in) :: tau + integer, intent(out) :: k, kp1 + integer :: j, i, m + do j = 1, n + if (abs(a(j, j)) <= tau) exit + end do + k = j - 1 + kp1 = j + do i = 1, n + kp1 = kp1 + i + end do + m = n + do i = 1, m, 2 + m = m - 1 + end do + k = k + i + end subroutine pseudorank + + function bump(x, cnt) result(y) + real(r8), intent(in) :: x + integer, intent(inout) :: cnt + real(r8) :: y + cnt = cnt + 1 + y = x * 2.0_r8 + end function bump + + subroutine search(x, cnt, alpha, t) + real(r8), intent(in) :: x + integer, intent(inout) :: cnt + real(r8), intent(out) :: alpha, t + alpha = bump(x, cnt) + t = 1.0_r8 + bump(x, cnt) + end subroutine search + end module emit_mod """ @@ -412,13 +469,20 @@ real(r8) :: y y = x + 1.0_r8 end function rise + + function tail_sum(n, x) result(y) + integer, intent(in) :: n + real(r8), intent(in) :: x(*) + real(r8) :: y + y = sum(x(1:n)) + end function tail_sum end module sibling_mod """ CALLER = """\ module caller_mod use precision_mod, only: r8 => wp_r8 - use sibling_mod, only: cscale, rise + use sibling_mod, only: cscale, rise, tail_sum implicit none contains subroutine drive(a, s) @@ -426,6 +490,7 @@ real(r8), intent(inout) :: s call cscale(a, s) s = rise(s) + s = tail_sum(2, a(3)) end subroutine drive end module caller_mod """ @@ -669,6 +734,94 @@ def test_do_bounds_shift_by_the_sign_of_the_step(sources: dict[str, Path]) -> No ) +def test_a_do_index_read_after_the_loop_gets_its_completion_value( + sources: dict[str, Path], +) -> None: + """Fortran leaves the index one past the last iteration when the loop + runs out (and at its start when it never runs); Python leaves it at the + last iteration. hfti's ``do j=1,ldiag; if (...) exit; end do; k=j-1`` + reads that value as the pseudorank, so a loop whose index is read + afterwards gets an ``else`` that sets it -- an EXIT skips it, as the + index keeps its value there on both sides. + + The completion is spelled with the one unified ``(low) + trips * step`` + form (``trips`` never negative), whatever the step: a unit step is + ``increment`` 1, so this reads ``(1) + max(0, ((n) - (1) + (1)) // (1)) + * (1)`` -- the same value as ``max(1, n + 1)`` and the same shape as the + stepped loops below (see the ``do i = 1, m, 2`` case).""" + statements, nodes = build(sources["emit_mod"], "pseudorank") + first = statements.render(pick(nodes, f03.Block_Nonlabel_Do_Construct), 1) + assert first[0] == " for j in range(1, n + 1):" + assert first[-2:] == [ + " else:", + " j = (1) + max(0, ((n) - (1) + (1)) // (1)) * (1)", + ] + + +def test_a_do_index_redefined_before_any_read_needs_no_completion_value( + sources: dict[str, Path], +) -> None: + """The next ``do i`` redefines ``i`` before anything reads it, so the + loop renders as it always did.""" + statements, nodes = build(sources["emit_mod"], "pseudorank") + second = statements.render(pick(nodes, f03.Block_Nonlabel_Do_Construct, 1), 1) + assert second == [" for i in range(1, n + 1):", " kp1 = (kp1 + i)"] + + +def test_a_do_whose_body_writes_a_bound_holds_the_bounds_it_started_with( + sources: dict[str, Path], +) -> None: + """Fortran evaluates the bounds once, at entry. The body writes ``m``, + which the upper bound names, so the completion value ``k = k + i`` reads + would be wrong recomputed from ``m`` afterwards: the bounds are held in + temporaries and both the range and the completion read those.""" + statements, nodes = build(sources["emit_mod"], "pseudorank") + third = statements.render(pick(nodes, f03.Block_Nonlabel_Do_Construct, 2), 1) + assert third[:4] == [ + " _dolo_i = 1", + " _dohi_i = m", + " _dost_i = 2", + " for i in range(_dolo_i, _dohi_i + 1, _dost_i):", + ] + assert third[-2:] == [ + " else:", + " i = (_dolo_i) + max(0, ((_dohi_i) - (_dolo_i) + (_dost_i)) // (_dost_i))" + " * (_dost_i)", + ] + + +def test_a_division_in_a_declared_bound_is_integer_division(sources: dict[str, Path]) -> None: + """``(n+1)*(n+2)/2`` -- the packed triangle SLSQP hands ``slsqpb`` -- is + an integer expression in Fortran; rendered with Python's ``/`` it was a + float, and the slice it sized refused it.""" + statements, _ = build(sources["emit_mod"], "pseudorank") + assert statements.expressions.bound("(n+1)*(n+2)/2") == "_f_int_div((n + 1) * (n + 2), 2)" + assert statements.expressions.bound("n+1") == "n+1" + + +def test_a_function_hands_its_inout_dummies_back_beside_its_result( + sources: dict[str, Path], +) -> None: + """SLSQP's ``linmin`` drives a line search through ``mode`` and eighteen + INOUT scalars; a translation returning the result alone kept them at + zero on every call. The function returns ``(result, *outputs)`` and a + whole-statement reference unpacks the tuple the way a CALL's is.""" + statements, nodes = build(sources["emit_mod"], "bump") + assert statements.returned_value() == "y, cnt" + statements, nodes = build(sources["emit_mod"], "search") + assert statements.render(nodes[0], 1) == [" alpha, cnt = bump(x, cnt)"] + + +def test_a_function_with_inout_dummies_inside_an_expression_is_refused( + sources: dict[str, Path], +) -> None: + """``1 + bump(x, cnt)`` has nowhere to put ``cnt``; refused by name + rather than rendered as an expression that drops the write.""" + statements, nodes = build(sources["emit_mod"], "search") + with pytest.raises(REFUSED, match="bump has OUT/INOUT dummy argument"): + statements.render(nodes[1], 1) + + def test_cycle_and_a_do_while_translate_directly(sources: dict[str, Path]) -> None: statements, nodes = build(sources["emit_mod"], "loops") do = pick(nodes, f03.Block_Nonlabel_Do_Construct) @@ -974,11 +1127,14 @@ def test_a_registered_external_reads_its_out_positions(sources: dict[str, Path]) def test_sequence_association_takes_leading_axes_whole(sources: dict[str, Path]) -> None: """``w(1, j)`` to a rank-1 formal is the whole column at ``j``, and a - rank-1 actual to a rank-2 formal refills it in column-major order.""" + rank-1 actual to a rank-2 formal refills it in column-major order -- + the first ``n*j`` cells of it, which is all the dummy spans: SLSQP's + ``nnls(w, n1, n1, m, ...)`` hands ``a(mda, n)`` the head of a longer + workspace, and reshaping the whole of ``w`` raised on the size.""" statements, nodes = build(sources["emit_mod"], "calls") assert statements.render(pick(nodes, f03.Call_Stmt, 8), 1) == [" vec2(w[:, j - 1])"] assert statements.render(pick(nodes, f03.Call_Stmt, 9), 1) == [ - " consume(n, j, np.reshape(flat, (n, j,), order='F'))" + " consume(n, j, np.reshape(flat[:(n) * (j)], (n, j,), order='F'))" ] @@ -996,18 +1152,49 @@ def test_an_element_for_an_assumed_size_dummy_is_the_tail_of_the_actual( " _f_copy_out(a[(n - 1):], np.ravel(tailv(a[(n - 1):]), order='F'))" ] assignments = [n for n in nodes if isinstance(n, f03.Assignment_Stmt)] - assert statements.render(assignments[-1], 1) == [" s = tail_norm(a[(n - 1):])"] + assert statements.render(assignments[-2], 1) == [" s = tail_norm(a[(n - 1):])"] -def test_an_element_for_a_rank_2_assumed_size_dummy_is_refused( +def test_an_element_for_a_rank_2_assumed_size_dummy_folds_the_tail( sources: dict[str, Path], ) -> None: - """``x(2, *)`` has no extent to reshape the tail to, so there is no - view for the callee's writes to land in; refused, not rendered as a - reshape to ``None``.""" + """``x(2, *)`` handed ``a(n)``: the tail from the element on, folded onto + the leading extent with the last axis taking the whole columns left -- + the runtime's ``_f_seq_tail`` -- and written back through + ``_f_seq_tail_out`` onto the same storage. It used to be refused for + having no extent to reshape to.""" statements, nodes = build(sources["emit_mod"], "calls") - with pytest.raises(REFUSED, match="assumed-size dummy"): - statements.render(pick(nodes, f03.Call_Stmt, 15), 1) + assert statements.render(pick(nodes, f03.Call_Stmt, 15), 1) == [ + " _f_seq_tail_out(a, (n - 1), tailm(_f_seq_tail(a, (n - 1), 2)))" + ] + + +def test_an_element_of_a_matrix_for_an_assumed_size_dummy_is_its_column_major_tail( + sources: dict[str, Path], +) -> None: + """SLSQP's ``dcopy(n, a(i, 1), la, ...)`` and ``h12(..., c(i, 1), lc, + ...)``: an element of a rank-2 actual for ``x(*)`` or ``x(2, *)`` is the + storage from that element to the end in column-major order. There is no + slice of a rank-2 array that spells it, so the runtime hands the callee + ``_f_seq_tail`` -- a view of a Fortran-contiguous actual -- and hands + the callee's array back whole to ``_f_seq_tail_out``, which writes it + onto the storage unless it is that view already; the whole matrix to + ``x(*)`` is the same thing from position 0. Every such call was refused + as "only a view when both are rank-1", which deferred every block that + recovers a matrix row.""" + statements, nodes = build(sources["emit_mod"], "calls") + start = "(2 - 1) + (j - 1) * 1 * np.size(m2, 0)" + assert statements.render(pick(nodes, f03.Call_Stmt, 16), 1) == [ + f" _f_seq_tail_out(m2, {start}, tailv(_f_seq_tail(m2, {start})))" + ] + assert statements.render(pick(nodes, f03.Call_Stmt, 17), 1) == [ + f" _f_seq_tail_out(m2, {start}, tailm(_f_seq_tail(m2, {start}, 2)))" + ] + assert statements.render(pick(nodes, f03.Call_Stmt, 18), 1) == [ + " _f_seq_tail_out(m2, 0, tailv(_f_seq_tail(m2, 0)))" + ] + assignments = [n for n in nodes if isinstance(n, f03.Assignment_Stmt)] + assert statements.render(assignments[-1], 1) == [f" s = tail_norm(_f_seq_tail(m2, {start}))"] def test_a_reshape_reads_the_callee_s_bound_in_whatever_case_it_was_written( @@ -1019,7 +1206,7 @@ def test_a_reshape_reads_the_callee_s_bound_in_whatever_case_it_was_written( actual it passed.""" statements, nodes = build(sources["emit_mod"], "calls") assert statements.render(pick(nodes, f03.Call_Stmt, 10), 1) == [ - " spread_it(2, j, np.reshape(flat, (2, j,), order='F'))" + " spread_it(2, j, np.reshape(flat[:(2) * (j)], (2, j,), order='F'))" ] @@ -1051,17 +1238,58 @@ def test_a_companion_generic_dispatches_to_its_specific(sources: dict[str, Path] assert statements.render(nodes[1], 1) == [" s = _sib.rise(s)"] +def test_a_companion_function_reference_binds_its_actuals_by_formal( + sources: dict[str, Path], +) -> None: + """Sequence association reaches a sibling's function too: ``ddot(n, + w(i4), 1, w(iff), 1)`` into a translated BLAS is bound to ``dx(*)`` and + ``dy(*)`` the way a call to a procedure of this file is, so the callee + gets the tail of the array and not two scalars to subscript.""" + sibling = interface.extract(sources["sibling_mod"], kind_assumptions=KINDS) + remotes = {s["name"]: Remote("_sib", s["name"]) for s in sibling["subprograms"]} + statements, nodes = build( + sources["caller_mod"], "drive", companions=(sibling,), remotes=remotes + ) + assert statements.render(nodes[2], 1) == [" s = _sib.tail_sum(2, a[(I_3 - 1):])"] + + # --- I/O and control --------------------------------------------------------- -def test_writes_split_on_whether_dataflow_survives(sources: dict[str, Path]) -> None: - """A log write carries nothing a differential can compare; an internal - write assigns to a character variable, which is real dataflow.""" +def test_writes_split_on_where_the_records_go(sources: dict[str, Path]) -> None: + """Three destinations, three translations. ``write(*, ...)`` is a log and + stays the stub it has always been; an INTERNAL write assigns a character + variable; a write to a unit an OPEN here connected to a file puts records + in that file, which for a subprogram whose only product is the file is the + whole translation (see the ADVANCE= test below).""" statements, nodes = build(sources["emit_mod"], "io") - assert statements.render(nodes[0], 1) == [" pass # write(*,...) log — no dataflow"] - assert statements.render(nodes[1], 1) == [" line = _f_list_write(s, a[0])"] + assert statements.render(nodes[1], 1) == [" pass # write(*,...) log — no dataflow"] + assert statements.render(nodes[2], 1) == [" line = _f_list_write(s, a[0])"] with pytest.raises(REFUSED): - statements.render(nodes[2], 1) # iostat= is control flow, not logging + statements.render(nodes[3], 1) # iostat= is control flow, not logging + + +def test_a_read_with_pos_seeks_before_it_reads(sources: dict[str, Path]) -> None: + """POS= is stream access: where in the file the values start, counted in + bytes from one. Refusing it deferred the one statement that says where a + program's header ended and its binary payload began.""" + statements, nodes = build(sources["emit_mod"], "io") + assert statements.render(nodes[4], 1) == [ + " _, ccode = _f_read(u, None, [('str', None, 1)], pos=(offset - 1))" + ] + + +def test_a_non_advancing_write_carries_advance_to_the_runtime( + sources: dict[str, Path], +) -> None: + """ADVANCE='no' says the record does not end here, which is a property of + the file the statement writes -- so it is carried to ``_f_write`` rather + than refused. An *internal* write has one record and nowhere to put it, + so ADVANCE= is refused there instead.""" + statements, nodes = build(sources["emit_mod"], "io") + assert statements.render(nodes[5], 1) == [" _f_write(u, '(a1)', [ccode], advance='no')"] + with pytest.raises(REFUSED): + statements.render(nodes[6], 1) def test_return_carries_the_out_arguments(sources: dict[str, Path]) -> None: @@ -1230,25 +1458,55 @@ def test_error_stop_ends_the_program_the_way_stop_does(sources: dict[str, Path]) assert statements.render(node, 1) == [" raise SystemExit(\"'nothing to do'\") # ERROR STOP"] -def test_file_positioning_carries_no_dataflow(sources: dict[str, Path]) -> None: - """REWIND and BACKSPACE move a file pointer and write no variable, so - there is nothing for a read/write gate to compare and nothing lost by - dropping them -- the same reading OPEN and CLOSE already get.""" +def test_file_positioning_moves_the_position_a_later_read_reads( + sources: dict[str, Path], +) -> None: + """REWIND and BACKSPACE write no variable, but the position they move is + what the next READ reads from -- a stub left the loop that rewinds and + re-reads a file reading the same records twice -- and the unit they name + is a read the gate compares.""" + statements, nodes = build(sources["emit_mod"], "io_edges") + assert statements.render(pick(nodes, f03.Rewind_Stmt), 1) == [" _f_rewind(u)"] + assert statements.render(pick(nodes, f03.Backspace_Stmt), 1) == [" _f_backspace(u)"] + + +def test_the_connection_statements_carry_their_writes(sources: dict[str, Path]) -> None: + """``newunit=`` is where OPEN puts the unit it allocated, and a stub left + it at whatever it held; CLOSE names a unit and nothing else.""" statements, nodes = build(sources["emit_mod"], "io_edges") - assert statements.render(pick(nodes, f03.Rewind_Stmt), 1) == [" pass # REWIND (I/O stub)"] - assert statements.render(pick(nodes, f03.Backspace_Stmt), 1) == [ - " pass # BACKSPACE (I/O stub)" + assert statements.render(pick(nodes, f03.Open_Stmt), 1) == [ + " _, u2 = _f_open(None, name, status='old')" ] + assert statements.render(pick(nodes, f03.Close_Stmt), 1) == [" _f_close(u)"] -def test_inquire_is_refused_because_its_specifiers_are_writes(sources: dict[str, Path]) -> None: - """The one I/O statement in this group that is not a stub, and where the - pipeline this was migrated from differs: it renders INQUIRE as ``pass``. +def test_inquire_assigns_every_specifier_it_can_answer(sources: dict[str, Path]) -> None: + """Where the pipeline this was migrated from renders INQUIRE as ``pass``: ``opened=ok`` writes ``ok``, and a ``pass`` leaves it at whatever it held - while the read/write gate is told nothing happened.""" + while the read/write gate is told nothing happened. A specifier the + runtime cannot answer is still refused, by name.""" + statements, nodes = build(sources["emit_mod"], "io_edges") + inquires = [n for n in nodes if isinstance(n, f03.Inquire_Stmt)] + assert statements.render(inquires[0], 1) == [" ok = _f_inquire(u, None, 'opened')"] + with pytest.raises(REFUSED, match="ENCODING="): + statements.render(inquires[1], 1) + + +def test_read_unpacks_its_item_list_and_its_iostat(sources: dict[str, Path]) -> None: + """A READ writes every item in its list, so the translation is the + assignment those writes make -- with IOSTAT= in front of them, because a + statement that asks for the status does not abort on a bad record.""" + statements, nodes = build(sources["emit_mod"], "io_edges") + assert statements.render(pick(nodes, f03.Read_Stmt), 1) == [ + " ios, x = _f_read(u, None, [('float64', None, None)], strict=False)" + ] + + +def test_print_reads_its_item_list(sources: dict[str, Path]) -> None: + """A ``pass`` told the read/write gate that a statement reading ``x`` read + nothing.""" statements, nodes = build(sources["emit_mod"], "io_edges") - with pytest.raises(REFUSED, match="OPENED="): - statements.render(pick(nodes, f03.Inquire_Stmt), 1) + assert statements.render(pick(nodes, f03.Print_Stmt), 1) == [" _f_print(None, x)"] def test_a_data_implied_do_is_expanded_in_definition_order(sources: dict[str, Path]) -> None: diff --git a/tests/test_numpy_subprograms.py b/tests/test_numpy_subprograms.py index 0b1110c..63a53ee 100644 --- a/tests/test_numpy_subprograms.py +++ b/tests/test_numpy_subprograms.py @@ -88,6 +88,12 @@ return end function total + function ranking(a) result(b) + integer, intent(in) :: a(:) + integer :: b(size(a)) + b = a + end function ranking + function pick(n) result(t) integer, intent(in) :: n real(r8) :: t @@ -121,6 +127,16 @@ k = k + 1 end subroutine climb + subroutine sized_locals(x, c) + real(r8), intent(in) :: x(:) + real(r8), intent(out) :: c(0:, :) + real(r8) :: knots(0:4, size(x) - 1) + real(r8) :: band(5, 2*size(c, 2)) + knots = 0.0_r8 + band = 0.0_r8 + c = 0.0_r8 + end subroutine sized_locals + end module asm_mod """ @@ -164,10 +180,12 @@ def node_of(source: Path, name: str) -> Any: def test_the_signature_reorders_by_intent(source: Path) -> None: """An optional OUT is not a parameter but a ``want_`` sentinel; an - optional IN becomes a keyword; a plain OUT vanishes from the def line - entirely, because the callee owns its buffer and returns it.""" + optional IN becomes a keyword. An assumed-shape OUT stays a parameter: + its extent is the actual's, which only the caller has, so the caller's + storage is passed in and handed back (see + ``test_out_arguments_are_allocated_or_zeroed``).""" lines, _ = build(source).render(node_of(source, "work"), "work") - assert lines[0] == "def work(n, a, b, want_opt=False, flags=None):" + assert lines[0] == "def work(n, a, b, out1, want_opt=False, flags=None):" def test_the_return_tuple_carries_every_out_intent(source: Path) -> None: @@ -223,11 +241,16 @@ def test_module_state_written_becomes_global(source: Path) -> None: def test_out_arguments_are_allocated_or_zeroed(source: Path) -> None: - """An assumed-shape OUT borrows the shape of a same-rank assumed-shape - IN argument -- Fortran took the extent from the actual, and the donor is - the only place that extent still exists.""" + """An assumed-shape OUT is the caller's buffer, not a fresh array sized + off a same-rank assumed-shape IN sibling. That donor was a guess about + the caller, and BVLS is where it was wrong: ``x(:)`` and ``w(:)`` are + ``n``-vectors while the only rank-1 donor, ``b(:)``, is an ``m``-vector, + so the translation sized its solution off the wrong axis and the f2py + reference could not allocate an ``intent(out)`` of extent ``:`` at all. + An optional scalar OUT is still the callee's to define.""" lines, _ = build(source).render(node_of(source, "work"), "work") - assert " out1 = np.zeros(np.shape(a), dtype=np.float64)" in lines + assert not any(line.strip().startswith("out1 = np.zeros(") for line in lines) + assert lines[0].startswith("def work(n, a, b, out1,") assert " opt = 0.0 # optional OUT: may not be assigned" in lines @@ -286,6 +309,32 @@ def test_a_function_result_is_preinitialized(source: Path) -> None: assert " t = 0.0" in lines +def test_an_array_result_is_preinitialized_once_and_in_its_own_dtype(source: Path) -> None: + """The array form and the scalar forms were alternatives written as + separate ``if``s, so ``integer :: b(size(a))`` got both: a float64 buffer + and then ``b = 0``, which rebound the name to a scalar and made the first + store into the result raise TypeError. Every integer-, logical- and + character-valued array function was unrunnable, which is where the + corpus's ``iargsort`` and ``rargsort`` stopped.""" + lines, _ = build(source).render(node_of(source, "ranking"), "ranking") + assert " b = np.zeros((np.size(a),), dtype=np.int32)" in lines + assert " b = 0" not in lines + + +def test_an_extent_is_a_term_of_a_bound_not_only_a_whole_one(source: Path) -> None: + """``size(a)`` alone resolved; ``size(x) - 1`` and ``2*size(c, 2)`` did + not. The inquiry was substituted into the bound text first and the + tokenizer that read what came back had no token for the ``.`` in + ``np.size(x)``, so every automatic array sized off an argument by + arithmetic -- the spline coefficient tables, their band matrices -- + 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 [entry for entry in report if entry["status"] == "agent_queue"] == [] + + # --- blocks ------------------------------------------------------------------ @@ -588,3 +637,37 @@ def test_a_local_parameter_from_a_use_imported_constant_is_spelled_through_its_c ) lines, _ = assembler.render(node_of(path, "nearly"), "nearly") assert " delta = _basic.SMALL" in lines + + +POWERS = """\ +module powers_mod + implicit none +contains + subroutine fill(x, a) + real, intent(in) :: x + real, intent(out) :: a(4) + integer :: j + do j = 1, 4 + a(j) = x**(j-1) + end do + end subroutine fill +end module powers_mod +""" + + +def test_a_runtime_integer_exponent_is_lowered_the_way_a_literal_one_is(tmp_path: Path) -> None: + """``x**(j-1)`` inside a loop has no literal for ``expand_power`` to + expand, so what was emitted was Python's ``**`` -- a libm ``pow`` call + gfortran makes for no integer exponent, literal or not. One to two ULP, + on the matrix ``spline3pars`` builds its end conditions from, is enough + to fail the gate for the caller. + + Under a profile that does not expand a literal exponent either, nothing + changes: the two decisions are the same fact about the reference binary. + """ + path = tmp_path / "powers.f90" + path.write_text(POWERS) + lowered = "\n".join(build(path, profile="gfortran").render(node_of(path, "fill"), "fill")[0]) + assert "_f_powi(x, ((j - 1)))" in lowered + left = "\n".join(build(path, profile="ifx").render(node_of(path, "fill"), "fill")[0]) + assert "(x ** ((j - 1)))" in left diff --git a/tests/test_numpy_translate.py b/tests/test_numpy_translate.py index 8f1a8f8..679d69e 100644 --- a/tests/test_numpy_translate.py +++ b/tests/test_numpy_translate.py @@ -962,3 +962,176 @@ def test_an_absent_optional_inout_leaves_its_slot_in_the_return(tmp_path: Path) sys.path.remove(str(out)) for suffix in ("_numpy", "_constants", "_use_constants"): sys.modules.pop(f"optinout_mod{suffix}", None) + + +# --- the shapes the polyroots corpus module put in front of the gate ---------- + +POLYROOTS_SHAPES = """\ +module shapes_mod + implicit none + integer, parameter :: wp = kind(1.0d0) + real(wp), parameter :: pi = acos(-1.0_wp) +contains + + subroutine cshape(opi, n, out) + integer, intent(in) :: n + real(wp), intent(in) :: opi(n) + real(wp), intent(out) :: out(n) + real(wp), dimension(n) :: pi + integer :: i + do i = 1, n + pi(i) = opi(i) + end do + call use_pi(out(1)) + out(2:n) = pi(2:n) + contains + subroutine use_pi(r) + real(wp), intent(out) :: r + r = pi(1) + end subroutine use_pi + end subroutine cshape + + subroutine rot(x, y, z) + real(wp), intent(in) :: x + real(wp), intent(out) :: y, z + real(wp) :: a, b + a = x + b = 2.0_wp * x + call helper(y) + z = func(y) + contains + subroutine helper(r) + real(wp), intent(out) :: r + r = a + b + end subroutine helper + function func(q) result(res) + real(wp), intent(in) :: q + real(wp) :: res + res = q * a + end function func + end subroutine rot + + subroutine spin(x, y) + real(wp), intent(in) :: x + real(wp), intent(out) :: y + y = func(x) + contains + function func(q) result(res) + real(wp), intent(in) :: q + real(wp) :: res + res = q + scale(q, 2) + end function func + function scale(q, k) result(s) + real(wp), intent(in) :: q + integer, intent(in) :: k + real(wp) :: s + s = k * q + end function scale + end subroutine spin + + subroutine rescale(coeff, n, work) + integer, intent(in) :: n + real(wp), intent(in) :: coeff(n) + real(wp), intent(out) :: work(n) + real(wp) :: scale + integer :: k + scale = 1.0_wp / coeff(1) + do k = 1, n + work(k) = -coeff(k) * scale + end do + end subroutine rescale + + function spread(nn, pt) result(width) + integer, intent(in) :: nn + real(wp), intent(in) :: pt(nn) + real(wp) :: width + real(wp) :: max, min, x + integer :: i + max = 0.0_wp + min = huge(1.0_wp) + do i = 1, nn + x = pt(i) + if (x > max) max = x + if (x < min) min = x + end do + width = max - min + end function spread + + subroutine fill(deg, conv, alpha) + integer, intent(in) :: deg + integer, intent(out) :: conv(deg) + real(wp), intent(inout) :: alpha(deg+1) + integer :: i + conv = [(0, i=1,deg)] + alpha = [(alpha(i)*(3.8_wp*(i-1)+1),i=1,deg+1)] + end subroutine fill + + subroutine eig(n, a, wr, wi, info) + integer, intent(in) :: n + real(wp), intent(in) :: a(n, n) + real(wp), intent(out) :: wr(n), wi(n) + integer, intent(out) :: info + real(wp), dimension(1) :: vl, vr + real(wp) :: work(3*n) + interface + subroutine xgeev(jobvl, jobvr, n, a, lda, wr, wi, vl, ldvl, vr, ldvr, work, lwork, info) + implicit none + character :: jobvl, jobvr + integer :: info, lda, ldvl, ldvr, lwork, n + double precision :: a(lda, *), vl(ldvl, *), vr(ldvr, *), wi(*), work(*), wr(*) + end subroutine xgeev + end interface + call xgeev('N', 'N', n, a, n, wr, wi, vl, 1, vr, 1, work, 3*n, info) + end subroutine eig +end module shapes_mod +""" + + +@pytest.fixture(scope="module") +def shapes(tmp_path_factory: pytest.TempPathFactory): + from recast.executors.local import LocalExecutor + from recast.verify.rwset import ReadWriteSetVerifier + + tree = tmp_path_factory.mktemp("shapes") + (tree / "shapes_mod.f90").write_text(POLYROOTS_SHAPES) + frontend = FortranFrontend() + unit = next(u for u in frontend.discover(tree) if u.uid == "fortran:shapes_mod") + facts = frontend.analyze(unit, tree) + candidate = NumpyTranslation().apply(unit, facts, {"root": tree}) + verdict = ReadWriteSetVerifier().check(unit, candidate, tree, LocalExecutor(), {}) + return candidate, verdict + + +def test_every_polyroots_shape_translates_and_passes_the_read_write_gate(shapes) -> None: + """One module of the shapes ``polyroots_module`` failed the gate on -- 37 + of its 446 blocks disagreed and four were deferred: host variables handed + to internal procedures, a local array shadowing the module's ``pi``, + locals called ``max``, ``min`` and ``scale``, two hosts each with a + ``func``, implied-do constructors, and LAPACK declared in an interface + block inside the caller. Nothing deferred, every block agreeing.""" + candidate, verdict = shapes + assert candidate.deferred == [] + assert verdict.confidence.value == "sampled", verdict.metrics.get("failures") + assert verdict.metrics["blocks_matched"] == verdict.metrics["blocks_checked"] > 20 + + +def test_the_protocol_names_renamed_internals_and_declared_externals(shapes) -> None: + """Two hosts' ``func`` come out as ``rot__func`` and ``spin__func``, and + ``xgeev`` is called by name with no body anywhere; each is a call, not a + read of the callee, only if the protocol lists it.""" + candidate, _ = shapes + procedures = set(candidate.notes["rwset"]["procedures"]) + assert {"rot__func", "spin__func", "func", "xgeev", "scale"} <= procedures + + +def test_spellings_the_gate_depends_on(shapes) -> None: + candidate, _ = shapes + text = candidate.files[Path("shapes_mod_numpy.py")].decode() + # A local called ``max`` is renamed around the builtin the same file calls. + assert " max_ = 0.0" in text and "width = (max_ - min_)" in text + # The host's array ``pi`` reaches ``use_pi`` as a parameter, not as the constant PI. + body = text.split("def use_pi(")[1].split("\ndef ")[0] + assert "PI" not in body and "pi[0]" in body + # A rank-1 actual for ``vl(ldvl, *)`` fills the dummy in column-major order. + assert "np.reshape(vl, (1, -1), order='F')" in text + assert "xgeev('N', 'N', n, a, n, wr, wi, " in text diff --git a/tests/test_references.py b/tests/test_references.py new file mode 100644 index 0000000..f9ac947 --- /dev/null +++ b/tests/test_references.py @@ -0,0 +1,186 @@ +"""Tests for the reference implementations recast supplies for a missing library. + +The whole value of :mod:`recast.references` is that its two spellings of one +procedure -- the Fortran the oracle build compiles and the Python the +translation calls -- produce the same bits. Nothing structural can check that: +it is a claim about rounding, and the only way to hold it is to build both and +run them over the same draws, which is what the last test here does. +""" + +from __future__ import annotations + +import importlib.util +import shutil +import subprocess +import sys +from pathlib import Path +from typing import Any + +import pytest + +pytest.importorskip("numpy", reason="needs recast-engine[verify]") + +import numpy as np + +from recast import references + +GFORTRAN = shutil.which("gfortran") +MESON = importlib.util.find_spec("mesonbuild") is not None + +WRAPPER = """\ +subroutine w_dgesv(n, nrhs, a, lda, ipiv, b, ldb, info) + implicit none + integer, intent(in) :: n, nrhs, lda, ldb + integer, intent(inout) :: ipiv(lda), info + double precision, intent(inout) :: a(lda,lda), b(ldb,nrhs) + call dgesv(n, nrhs, a, lda, ipiv, b, ldb, info) +end subroutine w_dgesv + +subroutine w_dgbsv(n, kl, ku, nrhs, ab, ldab, m, ipiv, b, ldb, info) + implicit none + integer, intent(in) :: n, kl, ku, nrhs, ldab, ldb, m + integer, intent(inout) :: ipiv(n), info + double precision, intent(inout) :: ab(ldab,m), b(ldb,nrhs) + call dgbsv(n, kl, ku, nrhs, ab, ldab, ipiv, b, ldb, info) +end subroutine w_dgbsv +""" + + +def _python_side() -> Any: + """The emitted Python, run the way a generated module runs it.""" + namespace: dict[str, Any] = {"np": np} + text = "\n".join(references.python_for(references.SUPPORTED)) + exec(compile(text, "", "exec"), namespace) + return namespace + + +# --- what is supplied and what is not ---------------------------------------- + + +def test_only_the_named_procedures_are_supplied() -> None: + """A name recast has no implementation for is still declared, still + undefined, and still disclaims its callers -- an audited shim is what + covers those, and it is the operator's to write.""" + assert references.supported(["DGESV", "dgbsv", "dsyevd", "ilaenv"]) == ["dgbsv", "dgesv"] + assert references.supported(["dsyevd"]) == [] + assert references.fortran_for(["dsyevd"]) == "" + assert references.python_for(["dsyevd"]) == [] + + +def test_the_fortran_defines_a_global_symbol_not_a_module() -> None: + """The callers were compiled against the interface their own tree + declared; what they need resolved is the external symbol of that name.""" + text = references.fortran_for(["dgesv"]) + assert "\nsubroutine dgesv(" in text + assert "module" not in text.replace("implementations", "") + assert "recast_ref_gepp" in text, "the shared elimination has to come with it" + + +def test_the_python_defines_the_names_the_translation_calls() -> None: + """``call dgbsv(...)`` in a module that ``use``s the interface module is + emitted as ``_lapack.dgbsv(...)``, so the definition has to land in the + translation of *that* module under exactly that name.""" + side = _python_side() + assert callable(side["dgesv"]) + assert callable(side["dgbsv"]) + + +# --- the two sides are one implementation ------------------------------------ + + +def test_a_singular_matrix_refuses_rather_than_returning_a_number() -> None: + """A translated call cannot write INFO back into its caller's scalar, so + the caller's ``if (info /= 0) call stop_error(...)`` cannot fire on this + side. Stopping the way a translated ERROR STOP stops is the honest + remainder: the differential calls the candidate first and draws again.""" + side = _python_side() + a = np.asfortranarray(np.zeros((2, 2))) + b = np.asfortranarray(np.ones((2, 1))) + with pytest.raises(SystemExit): + side["dgesv"](2, 1, a, 2, np.zeros(2, dtype=np.int32), b, 2, 0) + + +def test_a_dummy_whose_leading_dimension_is_not_its_extent_is_refused() -> None: + """The Python reads a Fortran ``(LD,*)`` dummy as a rank-2 array whose own + first extent is LD. Anything else is a sequence association it cannot see + through, and reading the wrong element silently is the one outcome worth + refusing.""" + side = _python_side() + a = np.asfortranarray(np.eye(3)) + b = np.asfortranarray(np.ones((3, 1))) + with pytest.raises(SystemExit): + side["dgesv"](2, 1, a, 2, np.zeros(3, dtype=np.int32), b, 3, 0) + + +@pytest.mark.skipif( + GFORTRAN is None or not MESON, + reason="needs a Fortran compiler and the meson backend (recast-engine[verify])", +) +def test_the_two_sides_are_bit_for_bit_one_implementation(tmp_path: Path) -> None: + """The only property that makes this worth having. + + If the Fortran and the Python drift by a single ULP, every subprogram + that reaches one of these fails the differential -- and it fails as a + translation defect, pointing at the caller rather than at this file. So + both are built and run over the same draws, and the comparison is on the + bytes. + """ + (tmp_path / "refs.f90").write_text(references.fortran_for(references.SUPPORTED)) + (tmp_path / "wrap.f90").write_text(WRAPPER) + flags = "-O1 -fno-fast-math -ffp-contract=off" + built = subprocess.run( + [ + sys.executable, + "-m", + "numpy.f2py", + "-c", + "--build-dir", + "build", + "wrap.f90", + "refs.f90", + "-m", + "refs", + f"--f90flags={flags}", + "--backend", + "meson", + ], + cwd=tmp_path, + capture_output=True, + text=True, + check=False, + ) + assert built.returncode == 0, built.stdout[-3000:] + built.stderr[-3000:] + + sys.path.insert(0, str(tmp_path)) + try: + fortran = importlib.import_module("refs") + finally: + sys.path.remove(str(tmp_path)) + python = _python_side() + + rng = np.random.default_rng(20260905) + for _ in range(40): + n = int(rng.integers(1, 7)) + a = np.asfortranarray(rng.uniform(-10.0, 10.0, size=(n, n))) + b = np.asfortranarray(rng.uniform(-10.0, 10.0, size=(n, 1))) + ipiv = np.zeros(n, dtype=np.int32) + mine = (a.copy(order="F"), b.copy(order="F"), ipiv.copy()) + fortran.w_dgesv(n, a, ipiv, b, np.array(0, dtype=np.int32)) + python["dgesv"](n, 1, mine[0], n, mine[2], mine[1], n, 0) + assert a.tobytes() == mine[0].tobytes() + assert b.tobytes() == mine[1].tobytes() + assert (ipiv == mine[2]).all() + + for _ in range(40): + n = int(rng.integers(2, 10)) + kl, ku = int(rng.integers(0, 3)), int(rng.integers(0, 3)) + ldab = 2 * kl + ku + 1 + ab = np.asfortranarray(rng.uniform(-10.0, 10.0, size=(ldab, n))) + b = np.asfortranarray(rng.uniform(-10.0, 10.0, size=(n, 1))) + ipiv = np.zeros(n, dtype=np.int32) + mine = (ab.copy(order="F"), b.copy(order="F"), ipiv.copy()) + fortran.w_dgbsv(kl, ku, ab, ipiv, b, np.array(0, dtype=np.int32)) + python["dgbsv"](n, kl, ku, 1, mine[0], ldab, mine[2], mine[1], n, 0) + assert ab.tobytes() == mine[0].tobytes(), "the factors, over the band pivoting fills" + assert b.tobytes() == mine[1].tobytes() + assert (ipiv == mine[2]).all() diff --git a/tests/test_rwset_verifier.py b/tests/test_rwset_verifier.py index b0885f2..af4ef8f 100644 --- a/tests/test_rwset_verifier.py +++ b/tests/test_rwset_verifier.py @@ -443,3 +443,90 @@ def test_the_where_constructs_masks_are_scaffolding() -> None: assert DISCARD.fullmatch(name), name for name in ("_wet", "_we", "_wn_x", "wn", "_f_copy_out", "x_we0_1"): assert not DISCARD.fullmatch(name), name + + +def test_a_masked_elsewhere_s_temporaries_are_scaffolding() -> None: + """A WHERE with a masked ELSEWHERE is emitted with ``_wn`` (what no + branch has claimed yet) and ``_we0_1`` (this branch's mask) beside + ``_wm``. They are the emitter's, like ``_wm``; counted, they failed + every such construct as reading and writing two names the source does + not have (SLSQP's ``enforce_bounds``).""" + import ast as pyast + + code = ( + "def clip(x, xl, xu):\n" + " _wm = (x < xl)\n" + " _wn = (~_wm)\n" + " x[...][_wm] = (xl)[_wm]\n" + " _we0_1 = (_wn & (x > xu))\n" + " _wn = (_wn & (~(x > xu)))\n" + " x[...][_we0_1] = (xu)[_we0_1]\n" + ) + reads, writes = span_rwset(pyast.parse(code), 2, 7, Protocol()) + assert reads == {"x", "xl", "xu"} + assert writes == {"x"} + + +def test_a_do_loop_s_held_bounds_are_scaffolding() -> None: + """A counted DO whose index is read after the loop, and whose body writes + a name its bounds use, holds the bounds in ``_dolo_i``/``_dohi_i``/ + ``_dost_i`` so the completion value is the one Fortran computed at + entry. They are the emitter's, like ``_wm``: the source reads ``m`` and + writes ``i`` and ``m``, and nothing else.""" + import ast as pyast + + code = ( + "def trim(m):\n" + " _dolo_i = 1\n" + " _dohi_i = m\n" + " _dost_i = 2\n" + " for i in range(_dolo_i, _dohi_i + 1, _dost_i):\n" + " m = m - 1\n" + " else:\n" + " i = (_dolo_i) + max(0, ((_dohi_i) - (_dolo_i) + (_dost_i)) // (_dost_i))" + " * (_dost_i)\n" + " return m, i\n" + ) + protocol = Protocol(scaffolding=frozenset({"range", "max"})) + reads, writes = span_rwset(pyast.parse(code), 2, 8, protocol) + assert reads == {"m"} + assert writes == {"i", "m"} + + +def test_an_assumed_size_write_back_writes_its_target() -> None: + """``_f_seq_tail_out(a, start, value)`` is the copy-out of a callee's + assumed-size dummy onto the caller's matrix -- a write of ``a``, the way + ``_f_copy_out`` is, with the start expression and the value read.""" + import ast as pyast + + code = ( + "def recover(a, w, i, n):\n" + " _f_seq_tail_out(a, (i - 1) + (1 - 1) * 1 * np.size(a, 0), np.ravel(w, order='F'))\n" + ) + protocol = Protocol(scaffolding=frozenset({"np", "_f_seq_tail_out"})) + reads, writes = span_rwset(pyast.parse(code), 2, 2, protocol) + assert reads == {"a", "i", "w"} + assert writes == {"a"} + + +def test_a_procedure_name_the_function_binds_is_a_variable() -> None: + """``rpqr79`` has a local ``scale``; ``cpoly`` contains a function + ``scale``, so the name is in ``procedures``. Python and Fortran both + resolve it inside ``rpqr79`` to the local, so a load of it there is a + read -- it was skipped as a call, and the block failed on the source's + read of it. At callee position the name stays a call, and in a function + that binds nothing of the name it stays a call everywhere.""" + import ast as pyast + + code = "def rpqr79(coeff):\n scale = 1.0 / coeff[0]\n work = coeff[1] * scale\n" + protocol = Protocol(procedures=frozenset({"scale", "rpqr79"})) + reads, writes = span_rwset(pyast.parse(code), 3, 3, protocol, own="rpqr79") + assert reads == {"coeff", "scale"} and writes == {"work"} + + other = "def cpoly(pt):\n bnd = scale(pt)\n scale = 2.0\n" + reads, writes = span_rwset(pyast.parse(other), 2, 2, protocol, own="cpoly") + assert reads == {"pt"} and writes == {"bnd"}, "callee position is control flow" + + unbound = "def cauchy(pt):\n bnd = scale * pt[0]\n" + reads, writes = span_rwset(pyast.parse(unbound), 2, 2, protocol, own="cauchy") + assert reads == {"pt"} and writes == {"bnd"}, "nothing binds scale here: a procedure"