Skip to content

feat(eq): eq_save, Eq.save() and the save tool of eq_mcp, with errors reported - #256

Merged
k-yoshimi merged 2 commits into
k-yoshimi:developfrom
HengyuLi-Ozaki-lab:feat/eq-save-error-reporting
Oct 17, 2026
Merged

k-yoshimi merged 2 commits into
k-yoshimi:developfrom
HengyuLi-Ozaki-lab:feat/eq-save-error-reporting

Conversation

@HengyuLi-Ozaki-lab

@HengyuLi-Ozaki-lab HengyuLi-Ozaki-lab commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

The first of the three pieces split out of #226, as asked there on 2026-10-13: eq_save with its error reporting, the header and the docstrings, so that the contract ships with the code.

develop has no eq_save yet (it is on #226's branch only), so this pull request brings the feature together with its error reporting rather than the error reporting alone. Reviewing it turned up four more ways in which a save could go wrong without saying so; they are closed here for eq's own entry points and listed below.

What is added

  • eq_save() in the C ABI (eq/eq_api.f90, eq/eq_api.h): writes the equilibrium of the last eq_run to the TASK/EQ binary file named by KNAMEQ, through EQSAVE.
  • Eq.save(path) in eqlib: sets KNAMEQ, calls eq_save.
  • The save tool of eq_mcp (12 tools now).

The file is the one MODELG=3 with KNAMEQ loads, in EQ and in the modules that read an equilibrium file (it is what #231's set_param_str("KNAMEQ", ...) for FP is for).

A save that wrote nothing is an error (#227 item 3)

EQSAVE has an argument now, IERR_OUT: 0 when the file was opened and written, FWOPEN's IERR when it refused the file, 9 when the file was opened and could not be written. It returned silently on a refused open before.

eq_save returns:

Code When
EQ_ERR_NOT_INIT before eq_init
EQ_ERR_INVALID, no file touched the fields do not hold the equilibrium of the present parameters (no successful eq_run since eq_init, a later solve or load that failed, or eq_set_param of anything but MODEFR and MODEFW since); a blank KNAMEQ; MODEFW other than 0 or 3
EQ_ERR_CALC_FAILED EQSAVE could not open the file (a directory that is missing or not writable; MODEFW=3 and an existing file) or not write it (an existing file without write permission); or no non-empty file exists afterwards
EQ_OK this call wrote the file

Looking for the file alone is not enough: a save to a path that already holds an earlier file would pass for a success (the repeat-save case of the 2026-07-26 exchange).

Four things beyond that exchange, each measured before it was changed:

  • An existing file without write permission ended the process. FWOPEN opens an existing file with STATUS='OLD' and no ACTION, so gfortran opens a read-only file for reading and FWOPEN returns 0; the first WRITE of EQSAVE then stopped the program (Fortran runtime error: Cannot write to file opened for READ, exit status 2). That WRITE has an IOSTAT now and the case is IERR_OUT = 9.

  • MODEFW 1, 2 and 4 to 7 can read unit 5. With 1 or 2 FWOPEN asks Y/N before it overwrites an existing file, with 4 to 7 it asks for the file name. Unit 5 of a stdio server is the JSON-RPC pipe: measured with MODEFW=4, the save returned success, left the named file as it was and wrote a file called after the next JSON-RPC line. eq_save takes 0 (overwrite) and 3 (refuse an existing file) only. eq_mcp described MODEFR and MODEFW to its clients as "frequency-space mode switch" and "wave-frequency mode switch"; they are the file modes of FROPEN and FWOPEN and say so now.

  • A save of something that is not an equilibrium. Before any run, a save returned success and wrote 45956 bytes that stop the process that loads them (XX setup_psig: SPL2D ERROR, then the STOP of eq/eqsub.f90). After a grid count raised following a run (NRGMAX and NZGMAX to 65) it wrote 96644 bytes that end the loading process the same way, since EQSAVE writes the counts as they stand over fields computed for the old ones. After a run that failed following a good one it wrote whatever the failed iteration had left, and returned success. eq_save therefore keeps a flag, set by a successful eq_run and cleared when an eq_run starts and by eq_set_param of anything but the two file modes, and refuses to save without it. This is stricter than Reconcile bpsi/develop base into develop (bpsi↔fork merge baseline, CI-green) #226's eq_save; say if you would rather have only the first case.

  • eq_run(1) reported a failed load as a success, or not at all. The check above rests on the run's return code. EQ_READ called EQCALQ after EQDSKR had failed and handed back EQCALQ's code, and left IERR unassigned for a MODELG that is no file format: on develop, in a build with run-time checks, the load of a missing EQDSK file (MODELG=5) ends the process at a STOP with exit status 0. EQ_READ now starts from IERR=0, calls EQCALQ only after a successful EQDSKR, and returns 1 for an unknown MODELG: three lines, the same ones the downstream product line has carried since September.

In eqlib: Eq.save takes a str or an os.PathLike; it refuses, with EqlibInvalidParamError, a path longer than the 80 bytes of KNAMEQ and a path that ends in a blank (Fortran drops the blank: save("result ") wrote result).

The other call sites of EQSAVE pass a variable: the S command of the eq menu (a read-only target no longer ends the program there either), and tr/f77/trfile.f, a file no Makefile builds, whose CALL EQSAVE(4) fitted neither the old routine nor the new one.

Tests

  • python/eqlib/tests/test_save.py, 20 cases in a temporary directory with relative names (KNAMEQ holds 80 characters): the saved file loads back with MODELG=3 and gives the grids and the two profiles it was saved with, exactly, and five scalars that are recomputed after the load, to 1e-9; a pathlib path and something that is no path; no run yet; a run that failed after a good one; a failed EQDSK load after a good run and a changed parameter; a parameter set after the run; the file modes set after the run; a missing directory; a blank path; 80 bytes taken and 81 refused; a path ending in a blank; a repeat save refused by MODEFW=3; a read-only file; MODEFW 1, 2, 4, 5, 6, 7.

  • python/eqlib/tests/test_load_errors.py, 3: the load of a missing file with MODELG 3 and 5, and with a MODELG that is no file format, is EqlibCalculationFailedError. Against develop's library the MODELG=5 case does not fail: it ends its process at the STOP (and under --forked the session with it); the MODELG=2 case passes there on this machine, where the unassigned code happened to be nonzero.

  • python/mcp-servers/eq_mcp/tests/test_save_tool.py, 6: the handler, the two error mappings, the tool list, one sequence through the real library, the two descriptions.

  • eq/tests/c_abi/test_negative.c: eq_save before eq_init. make -C eq eq_api_check_all does not link in a build without the graphics library (on develop neither, and CI does not run it); built against libeqapi.so directly, the test passes.

  • With a fault put in and the library rebuilt:

    Fault Tests that fail
    eq_save ignores EQSAVE's argument the repeat save, the read-only file
    EQSAVE's first WRITE without IOSTAT the read-only file
    no MODEFW check the six MODEFW cases
    no check for an equilibrium "no run yet", the sequence through the real library
    the flag not cleared when a run starts "a run that failed"
    the flag not cleared by a setter "a parameter set after the run"
    the flag cleared by the file modes too "the file modes set after the run", the repeat save
  • One real stdio session (initialize, init, save, run(mode=0), save, save into a missing directory): "invalid parameter: eq_save: rc=1" and no file before the run; a 45956-byte file after it; "calculation failed: eq_save: rc=3" for the missing directory.

python/ with CI's flags, macOS arm64, gfortran 15: 802 passed, 35 skipped, 1 xfailed (develop a0b58408: 773 passed). The 35 skips are the 19 Linux-only equivalence tests, 13 that need the mono image or TOTLIB_PATH, and 3 without a ti fixture; with the mono image, the three test_mono_* files, python/eqlib and eq_mcp: 130 passed, 2 skipped. Linux CI is the measurement of the equivalence tests. On the solver's side the change is EQSAVE's argument, the IOSTAT of its first WRITE and the three lines of EQ_READ, none of which a successful run reaches differently; the flag lives in eq_api.f90. Measured against develop: the state after a run is bit-identical for the two EQ fixtures that load a file and the analytic default (512 values each), and for tr_tst2, tr_iter01, tr_m0904, the TR defaults and a current ramp.

Not changed, and known

  • EQSAVE writes PSI, DELPSI and HJT as (NSG,NTG) blocks where the arrays are (NTG,NSG) and the reader reads them so: the same bytes when NSGMAX = NTGMAX (the default, 32 and 32), transposed extents otherwise. S. Terasaki reported it on Reconcile bpsi/develop base into develop (bpsi↔fork merge baseline, CI-green) #226. It is older than this change, and a fix alters the file for unequal grids, so I left it for its own pull request; say if you would rather have it here.
  • Only the first WRITE of EQSAVE has an IOSTAT. A failure later in the file (a full disk) still ends the program.
  • MODEFR=1 makes the load path ask for a file name on unit 5 in the same way. Not touched.
  • The flag is eq_api's own: set by eq_run, cleared by eq_run and eq_set_param. An equilibrium that another module computes through EQ inside the mono image does not set it, and a parameter changed by another route does not clear it: tot's eq: names call the registry directly, and in the mono image the other modules' setters write the shared plcomm (measured there: eq_run(0), tot_set_param("eq:NRGMAX", 65), eq_save returns EQ_OK). Moving the mark into eq_param_registry would cover tot's route; say if you want that here.
  • EQ_READ has other callers: eq_load (tr, fp, wr, wrx), EQLOAD (ob, wm, wmx) and the L and K commands of the eq menu. tr, fp, wr, wrx, wm and wmx ask for MODELG 3, 5 or 8, where a successful load is as before (K sets MODELG=5 itself). The L command passes on whatever MODELG is set (2 as 3), and ob/obprep.f90 calls it for any: for a MODELG that is no file format they now get IERR=1.
  • Unit 6. Without the stdio isolation of the companion pull request (fix(mcp): eq_mcp, tr_mcp and tot_mcp keep Fortran output off the JSON-RPC pipe #257), EQSAVE's messages reach the JSON-RPC pipe like the rest of EQ's output (5 of the 11 stdout lines of the session above are not JSON-RPC).
  • get_psi_rz, the other tool of the same group of Reconcile bpsi/develop base into develop (bpsi↔fork merge baseline, CI-green) #226, is not here. It can follow as a fourth piece or stay with Reconcile bpsi/develop base into develop (bpsi↔fork merge baseline, CI-green) #226, as you prefer.

Scope

Based on develop (a0b58408). It merges with the companion pull request for the stdio isolation (#257), with #231, #232, #233 and #240, without a conflict (trial merge of all of them together); it shares python/mcp-servers/eq_mcp/server.py with the companion and python/eqlib/_ffi.py with #240, in different hunks. #226 will take develop's version of these files when it is brought up to date.

🤖 Generated with Claude Code

… reported

Split out of k-yoshimi#226 at the maintainer's request: the save of an equilibrium
through the library, together with its error reporting (k-yoshimi#227 item 3), so
that the contract ships with the code.

What is added
- eq_save() in the C ABI (eq/eq_api.f90, eq/eq_api.h): writes the
  equilibrium of the last eq_run to the TASK/EQ binary file named by
  KNAMEQ, through EQSAVE.
- Eq.save(path) in eqlib: sets KNAMEQ, calls eq_save.
- The `save` tool of eq_mcp (12 tools now).
The file is the one MODELG=3 with KNAMEQ loads.

A save that wrote nothing is an error
- EQSAVE has an argument now, IERR_OUT: 0 when the file was opened and
  written; FWOPEN's IERR when it refused the file; 9 when the file was
  opened and its first record could not be written. That last case is an
  existing file without write permission: FWOPEN opens it for reading and
  returns 0, and the first WRITE used to end the program. EQSAVE returned
  silently on a refused open before.
- eq_save returns EQ_ERR_NOT_INIT before eq_init. It returns
  EQ_ERR_INVALID, touching no file, when the fields do not hold the
  equilibrium of the present parameters: no successful eq_run since
  eq_init, a later solve or load that failed (it leaves the fields
  part-way), or
  eq_set_param of anything but MODEFR and MODEFW since (EQSAVE writes the
  parameters and counts as they stand over fields computed for the old
  ones). What EQSAVE writes in those states can stop the process that
  loads it. It returns EQ_ERR_INVALID too for a blank KNAMEQ, and for
  MODEFW other than 0 (overwrite an existing file) or 3 (refuse one): with
  1 or 2 FWOPEN asks Y/N on unit 5 before it overwrites an existing file,
  with 4 to 7 it asks for the file name there, and unit 5 of a stdio
  server is the JSON-RPC pipe. It returns EQ_ERR_CALC_FAILED when EQSAVE
  reports that
  the file could not be opened (a directory that is missing or not
  writable, MODEFW=3 and an existing file) or not written, or when no
  non-empty file exists afterwards. Looking for the file alone is not
  enough: a save to a path that already holds an earlier file would pass
  for a success.
- eq_run(1) reports a load that failed, which the precondition above
  rests on. EQ_READ called EQCALQ after EQDSKR had failed and handed back
  EQCALQ's code, and left IERR unassigned for a MODELG that is no file
  format: on develop the load of a missing EQDSK file (MODELG=5) ends the
  process at a STOP, exit status 0, in a build with run-time checks. EQ_READ
  now starts from IERR=0, calls EQCALQ only after a successful EQDSKR, and
  returns 1 for an unknown MODELG. (The product line has had these three
  lines since September.)
- Eq.save takes a str or an os.PathLike, and refuses a path that ends in a
  blank (Fortran drops the blank and would write another file) or is
  longer than the 80 bytes of KNAMEQ, with EqlibInvalidParamError.
- The other call sites of EQSAVE pass a variable now: the `S` command of
  the eq menu, and tr/f77/trfile.f, a file no Makefile builds, whose
  `CALL EQSAVE(4)` fitted neither the old routine nor the new one.
- eq_mcp described MODEFR and MODEFW as "frequency-space mode switch" and
  "wave-frequency mode switch"; they are the file modes of FROPEN and
  FWOPEN, and say so now.

Tests
- python/eqlib/tests/test_save.py (20 cases, in a temporary directory with
  relative names): the saved file loads back with MODELG=3 and gives the
  grids and the two profiles it was saved with, exactly, and five scalars
  that are recomputed after the load, to 1e-9; a pathlib path and
  something that is no path; no run yet; a run that failed after a good
  one; a failed EQDSK load after a good run and a changed parameter; a
  parameter set after the run; the file modes set after the run; a
  missing directory; a blank path; 80 bytes taken and 81 refused; a path
  ending in a blank; a repeat save refused by MODEFW=3; a read-only file;
  MODEFW 1, 2, 4, 5, 6 and 7.
- python/eqlib/tests/test_load_errors.py (3): the load of a missing file
  with MODELG 3 and 5, and with a MODELG that is no file format, is
  EqlibCalculationFailedError.
- python/mcp-servers/eq_mcp/tests/test_save_tool.py (6): the handler, the
  two error mappings, the tool list, one sequence through the real
  library, the two descriptions.
- eq/tests/c_abi/test_negative.c: eq_save before eq_init. The Makefile
  target that runs it does not link in a build without the graphics
  library (on develop neither, and CI does not run it); built against
  libeqapi.so directly, it passes.
- With a fault put in and the library rebuilt: eq_save ignoring EQSAVE's
  argument fails the repeat-save and the read-only tests; EQSAVE's first
  WRITE without IOSTAT fails the read-only test; no MODEFW check fails the
  six MODEFW cases; no check for an equilibrium fails two tests; the flag
  not cleared when a run starts, or not cleared by a setter, fails the
  test of that case; the flag cleared by the file modes too fails two.
- One real stdio session (initialize, init, save, run(mode=0), save, save
  into a missing directory): "invalid parameter: eq_save: rc=1" and no
  file before the run; a 45956-byte file after it; "calculation failed:
  eq_save: rc=3" for the missing directory.

python/ with CI's flags, macOS arm64, gfortran 15: 802 passed, 35 skipped,
1 xfailed (develop a0b5840: 773 passed). The state after a run is
bit-identical with develop: the two EQ fixtures that load a file and the
analytic default (512 values each), and tr_tst2, tr_iter01, tr_m0904, the
TR defaults and a current ramp.

Not changed, and known
- EQSAVE writes PSI, DELPSI and HJT as (NSG,NTG) blocks where the arrays
  are (NTG,NSG) and the reader reads them so: the same bytes when NSGMAX =
  NTGMAX (the default, 32 and 32), transposed extents otherwise (reported
  on k-yoshimi#226). Older than this change, and a fix alters the file for unequal
  grids: left for its own pull request.
- Only the first WRITE of EQSAVE has an IOSTAT: a failure later in the
  file (a full disk) still ends the program.
- MODEFR=1 makes the load path ask for a file name on unit 5 in the same
  way; not touched.
- eq_save's precondition is eq_api's own: set by eq_run, cleared by
  eq_run and eq_set_param. An equilibrium that another module computes
  through EQ inside the mono image does not set it, and a parameter changed
  by another route does not clear it: tot's `eq:` names call the registry
  directly, and in the mono image the other modules' setters write the
  shared plcomm (measured there: eq_run(0), tot_set_param("eq:NRGMAX", 65),
  eq_save returns EQ_OK).
- EQ_READ is also reached through eq_load (tr, fp, wr, wrx), EQLOAD (ob,
  wm, wmx) and the L and K commands of the eq menu. tr, fp, wr, wrx, wm
  and wmx ask for MODELG 3, 5 or 8, where a successful load is as before
  (K sets MODELG=5 itself). The L command passes on whatever MODELG is set
  (2 as 3), and ob/obprep.f90 calls it for any: for a MODELG that is no
  file format they now get IERR=1.
- Without the stdio isolation of the companion pull request, EQSAVE's
  messages on unit 6 reach the JSON-RPC pipe like the rest of EQ's output
  (5 of the 11 stdout lines of the session above are not JSON-RPC).
- get_psi_rz, the other tool of the same group of k-yoshimi#226, is not here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@k-yoshimi k-yoshimi left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Review of 411971c (in-house + two independent Codex passes, plus a local build and test run; details at the end). The C ABI, the Python wrapper, the MCP tool, the doc sync and the test coverage are clean and consistent, and the EQ_READ fix is correct. Two things before merge, the rest are suggestions.

Before merge

1. EQSAVE still aborts on every record after the first (eq/eqfile.f90:49 is the only WRITE(21,IOSTAT=…); the ~30 WRITE(21) at :56-84 and the CLOSE(21) have no IOSTAT/ERR). ENOSPC, quota or EIO mid-file is a gfortran runtime error that ends the host process (pytest, the MCP server) and leaves a truncated file that EQRTSK's unguarded READs choke on later. The CHANGELOG, README and the eq_api.h text promise "an error return, not the end of the process"; today that holds for the EACCES-on-first-record case only. The least invasive shape is one ERR= label on every WRITE(21…) and the CLOSE that does CLOSE(21) + IERR_OUT=9 (or IOSTAT=IST on each with the same check). A /dev/full test (skip when absent) would pin it.

2. Keep the zero-argument EQSAVE as a compatibility entry. eqfile.f90, eqmenu.f90 and tr/f77/trfile.f are upstream TASK Fortran, and the fork rule is that changes to the eq/tr Fortran body go to the lead for sign-off; the C ABI, wrappers, registries, tests and docs do not. If the status-returning routine gets its own name (say EQSAVE_IERR(IERR_OUT)) and the old SUBROUTINE EQSAVE becomes a two-line wrapper that calls it and drops the code, eqmenu.f90 and trfile.f stay untouched and the upstream-visible diff shrinks to one file (plus the EQ_READ change, which is a genuine bug fix and should be shown to the lead either way). I will raise the sign-off question with @k-yoshimi; this item makes that conversation smaller.

Suggestions (not blocking; say which you take, the rest I will track)

  • g_has_equilibrium is bypassed by sibling modules in the mono image (libtotapi_mono.so, MONO_LIB_PATH): tot's eq: namespace calls eq_param_set / eq_param_set_str directly (tot/tot_param_registry.f90:150, :223), tr_init / fp_init / ti_init call eq_init (tr/tr_api.f90:94, fp/fp_api.f90:102, ti/ti_api.f90:106), and the tr/fp run paths call eq_load / eq_calc (tr/trmetric.f90:41,55, tr/trloop.f90:80, fp/fpprep.f90:66). After any of these the flag is still .TRUE. and eq_save writes whatever the fields hold. Standalone libeqapi.so is not affected. This is the same design class as #253 (tot's tr: setter bypassing tr's prepared flag), so I would be content with one sentence in eq_api.h and the README ("the flag tracks the eq_api entry points; in the mono image sibling modules can change the equilibrium without clearing it") and I will widen #253 to cover eq. If you prefer to fix it here, an invalidation hook that the registry setters call is the natural place.
  • Mono image: eq_save is refused after tot_init until MODEFW is set to 0. tot_api_init ends in pl_init, which sets MODEFW=5 (pl/plinit.f90:461); only eq_init sets it to 0 (eq/eqinit.f90:368). It fails safe (no unit-5 read), but worth a line in the docs.
  • MCP save overwrites any writable path by default (MODEFW=0). For an agent-facing tool an overwrite: bool = False argument that maps to MODEFW=3 unless set would be a safer default. Design preference, your call.
  • Eq.save sets KNAMEQ before the precondition check (python/eqlib/eqlib.py:341), so after EQ_ERR_INVALID the parameter state has changed even though the filesystem has not (a later run(1) loads from the new path). Restore it on failure, or say so in the docstring.
  • bytes paths and non-ASCII paths both end as EqlibInvalidParamError from _encode_name (the docstring says TypeError for non-str/non-PathLike, and does not mention the ASCII limit). State the restriction or os.fsdecode deliberately.
  • eq_api_set_param_str never clears the flag. That is right for KNAMEQ (it is the save target, and run(1) sets the flag itself after a successful load); a comment saying that none of the KNAM* strings feeds the solve would make the asymmetry intentional rather than accidental.
  • Stale text next to the edit: eq/eq_api.h:19-20 still says eq_run(0) returns EQ_ERR_NOT_IMPL pending L-4; docs/sphinx/modules/eq/{en,ja}/parameters.md still lists MODEFR/MODEFW as "(internal)" while server.py now documents them as the FROPEN/FWOPEN modes that save depends on.
  • python/eqlib/tests/test_eqlib.py:198: eq.save("/tmp/eq.bin") is a dead path (the closed-handle check raises first); a relative name avoids the /tmp literal.

Verified OK

  • Only eq/Makefile compiles eqfile.f90; the two updated call sites are the only EQSAVE callers (case-insensitive grep), and nothing builds tr/f77/, so the CHANGELOG sentence holds.
  • Implicit typing of IERR_OUT/IST/IERR is INTEGER; unit 21 is closed on every failure path that exists today; MODEFW ∈ {0,3} matches FWOPEN (MOD(MODEP,4) ∈ {1,2} or MODEP ≥ 4 read unit 5); INQUIRE SIZE=-1 is caught by <= 0.
  • EQ_READ: EQDSKR returns FROPEN's 7 on a missing file and used to be overwritten by EQCALQ; the new order is right and eq_load already checks the code before eq_bpsd_init/eqcalq.
  • All "11 → 12" tool counts updated; "six + eq_validate + eq_save" (design.md) and "eight" (eq_api.f90 header) agree; api-reference.md autoclass picks up save.
  • Local: fresh clone of this branch + unbuilt bpsd clone, make -C eq libeqapi.so → OK; EQLIB_PATH=… pytest python/eqlib/tests/{test_save,test_load_errors,test_eqlib}.py python/mcp-servers/eq_mcp/tests/{test_save_tool,test_server}.py --forked --timeout=120 --timeout-method=signal → 88 passed (macOS, gfortran).

Answers the review of 411971c on k-yoshimi#256.

Before merge, 1: only the first WRITE of EQSAVE was checked. What
happens when a later write is refused was measured with a file size
limit in a child process (macOS, gfortran 15; SIGXFSZ ignored, write()
returns EFBIG), in a library loaded by Python:

  - a test routine, one 160 kB record in one transfer: a plain WRITE
    ends the process ("Fortran runtime error: File too large"), IOSTAT=
    returns the error. That is the case the review describes;
  - a test routine, records of 16 kB: IOSTAT is 0 on every WRITE, on
    FLUSH and on CLOSE, and the file is short;
  - EQSAVE itself, limits of 16, 1024, 4096 and 20000 bytes over
    nothing, an empty file and files of 8, 3000 and 45956 bytes (20
    cases, on this commit): in 17 no statement reports anything and the
    file has the limit's length, of the 45956 bytes written; in the
    three with the 16-byte limit over a file that is not empty, the
    first WRITE, which had its IOSTAT, returns 27. On 411971c eq_save
    returned EQ_OK for a new file at each of the four limits.

So EQSAVE_IERR does both. Every WRITE after the first and the CLOSE
carry ERR=, for an error the run-time reports. And INQUIRE(UNIT=21,
SIZE=) before the CLOSE, which gives the length written to the unit, is
compared with INQUIRE(FILE=, SIZE=) after it; a shorter file returns 9
(when either length cannot be had, the comparison is left out). With
gfortran the two are equal for a regular file that was written, also
when a longer or a shorter file is overwritten (checked). The file name
is asked of the unit, because with MODEFW 4 to 7 FWOPEN opens the name
it read and not KNAMEQ. A device has length 0 and is reported short as
well. Not measured: a full disk, and Linux (/dev/full does not exist
here; the test for it runs where it does).

What is at the path after such a failure is left there (deleting what
the path names is not this routine's to decide). It is usually a short
file, and it must not be loaded: run(1) with MODELG=3 on a file cut at
4096 bytes ends the process in EQRTSK, whose READs have no IOSTAT. That
is outside this pull request; it is the item raised in the reply on
k-yoshimi#231. The header, the docstrings and the README say so. Measured too: a
save that leaves a short file leaves one descriptor open (+1 per save).

Before merge, 2: EQSAVE is argument-less again. The work is in
EQSAVE_IERR(IERR_OUT), which eq_save calls; EQSAVE calls it and drops
the code. eq/eqmenu.f90 and tr/f77/trfile.f are as on develop.

Suggestions taken:
  - eq_api.h and the README say that the "successful run" flag is what
    eq_api's own entry points saw, with examples of what bypasses it in
    the mono image.
  - The save tool of eq_mcp has overwrite (default false). It refuses an
    existing file with a message that names the argument (for a path
    that is not a regular file, a message that says that), and when it
    saves it sets MODEFW itself, 3 or 0.
  - Eq.save: the docstring says that KNAMEQ stays at the path once the
    path is accepted (eqlib has no getter to restore it from), and that
    a bytes path and a non-ASCII one are refused.
  - eq_api_set_param_str: a comment on why it leaves the flag alone.
  - eq_api.h no longer says eq_run(0) is not implemented; MODEFR and
    MODEFW are described in parameters.md (en, ja); the /tmp literal in
    test_eqlib.py is a relative name.

Not taken: a line that eq_save is refused after tot_init until MODEFW is
set. Measured in the mono image of this branch: after tot_init MODEFW is
0 and eq_run(0), eq_save write the file (rc 0). wr_api_init calls EQINIT
after pl_init, and EQINIT sets MODEFW to 0.

Tests (python/eqlib/tests/test_save.py, eq_mcp/tests/test_save_tool.py):
a write refused at 16 and at 4096 bytes is EqlibCalculationFailedError
with the "XX EQSAVE" line, the process alive and the file at the limit;
a control without a limit; /dev/full where it exists; the argument-less
EQSAVE writes the same bytes as eq_save (called with a null pointer, so
that an EQSAVE that takes an argument ends the process instead of
passing by accident); a bytes path and a non-ASCII path are refused and
touch nothing; the tool refuses an existing file by default and leaves
it unchanged, replaces it with overwrite, says "not a regular file" for
a directory, and FWOPEN's mode 3 holds when a file appears after the
tool's look. On the Fortran of 411971c four of these fail: the two
refused-write cases (the save reports success) and the two that call
EQSAVE without an argument (the process ends).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@HengyuLi-Ozaki-lab

Copy link
Copy Markdown
Collaborator Author

Both items are in, in one commit on 411971c7 (ae6463f5). Of the suggestions, seven are taken and one is not, with the measurement that speaks against it.

1. The writes after the first. Every WRITE(21…) after the first and the CLOSE carry ERR=, as you proposed. That alone did not make eq_save report the failure, so there is a second check; the reason is what a refused write does here. Measured with a file size limit in a child process (macOS, gfortran 15, -O0, the library loaded by Python, SIGXFSZ ignored so that write() returns EFBIG):

what is written result
a test routine, one 160 kB record in one transfer a plain WRITE ends the process ("Fortran runtime error: File too large"); with IOSTAT= the error is returned. This is the case you describe
a test routine, records of 16 kB IOSTAT is 0 on every WRITE, on FLUSH and on CLOSE; the file is short
EQSAVE, limits of 16, 1024, 4096 and 20000 bytes, over nothing, an empty file and files of 8, 3000 and 45956 bytes (20 cases) in 17 no statement reports anything and the file has the limit's length, of 45956 bytes written; in the three with the 16-byte limit over a file that is not empty, the first WRITE returns IOSTAT 27

So on 411971c7 a save to a new file under a limit did not end the process: eq_save returned EQ_OK and left a file of 16, 1024, 4096 or 20000 bytes. EQSAVE_IERR therefore also compares the length written to the unit (INQUIRE(UNIT=21, SIZE=) before the CLOSE) with the length of the file after it (INQUIRE(FILE=, SIZE=)), and returns 9 when the file is shorter; all 20 cases above are now an error. For a regular file that was written the two lengths are equal, also when a longer or a shorter file is overwritten (checked: no false alarm), and when either cannot be had the comparison is left out. The name is asked of the unit, since with MODEFW 4 to 7 FWOPEN opens the name it read and not KNAMEQ. A device has length 0 and is reported short as well.

The /dev/full test is there and skips where the device does not exist, which is here: its first run is this push's CI. Not measured: a full disk, and anything on Linux.

What is at the path after such a 9 is left there (it may name a device; deleting it is not this routine's decision), and the header, the docstring and the README say that it cannot be taken for the equilibrium and must not be loaded: run(1) with MODELG=3 on a file cut at 4096 bytes ends the process in EQRTSK. Those are the unguarded reads I asked about on #231; they are not touched here. In these measurements a save that leaves a short file also leaves one descriptor open.

2. EQSAVE keeps its entry. EQSAVE is argument-less again and calls EQSAVE_IERR(IERR_OUT), which eq_save calls directly. eq/eqmenu.f90 and tr/f77/trfile.f are as on develop, so the upstream Fortran this pull request touches is eq/eqfile.f90: the new routine with its checks, and the EQ_READ order from before. A test calls eqsave_ and compares its file with eq_save's.

Suggestions.

  • The flag and the mono image: the sentence is in eq_api.h and the README (the flag is what eq_api's own entry points saw; in the mono image sibling modules can change the equilibrium without clearing it), with your examples. No hook here; tot: tr: parameter updates after a run bypass tr_api's prepared flag (no re-prep, no #189 pre-run check) #253 can cover it.
  • eq_save refused after tot_init until MODEFW is 0: not taken, because I could not reproduce it. In the mono image of this branch MODEFW is 0 after tot_init, and eq_run(0) followed by eq_save writes the file (rc 0): the last init of tot_api_init is wr_api_init (wrx/wrx_api.f90:129-130), which calls EQINIT right after pl_init, and EQINIT sets it to 0. If you saw the refusal in a run, tell me the build and I will add the line.
  • overwrite: taken, default false. The tool refuses an existing file with a message that names the argument (for a path that is not a regular file, one that says that) and changes nothing; when it saves it sets MODEFW itself, 3 or 0, so FWOPEN's own refusal still holds when a file appears between the tool's look and the open (a test).
  • KNAMEQ set before the check: said in the docstring. eqlib has no getter to restore it from.
  • bytes and non-ASCII paths: the docstring states both restrictions; two tests (refused, nothing touched).
  • eq_api_set_param_str and the flag: the comment is there.
  • Stale text: eq_api.h no longer says eq_run(0) is not implemented; MODEFR and MODEFW are described in parameters.md (en, ja).
  • The /tmp literal: a relative name.

Measured. The present tests on the Fortran of 411971c7: 4 fail of 36 (the two refused-write cases, because the save reports success; the two that call EQSAVE without an argument, because the process ends). On this commit, after a clean build on macOS: 811 passed, 36 skipped, 1 xfailed with the CI flags; the EQ and TR states of the baseline cases are bit-identical to 411971c7's.

@k-yoshimi

Copy link
Copy Markdown
Owner

One more input for the g_has_equilibrium precondition, from #259 (the #253 fix, now up for review): tr / fp / ti's registries now count every successful set (<mod>_param_generation(), private counter advanced by the public *_param_set wrappers), and the API modules compare the count they saw at their last prep instead of clearing a flag in the setter. If eq_param_registry gets the same five lines, eq_api_run can snapshot eq_param_generation() after a successful solve / load and eq_save can refuse when the current count differs, which closes tot's eq: dispatch bypass (tot/tot_param_registry.f90:150, :223) without any hook in tot, and makes the MODEFR / MODEFW exemption a one-line comparison tweak (snapshot after setting those two, or exclude them in the registry wrapper). It would replace the clear in eq_api_set_param, not add to it. The sibling-module paths (eq_init from tr/fp/ti init, eq_load / eq_calc from trmetric / fpprep) stay out of reach of any registry mechanism; they are now tracked in #260, so a sentence in the docs is enough for them here.

@k-yoshimi k-yoshimi left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Re-review of ae6463f (in-house + Codex on the delta, plus a fresh-clone build and the eq test suites locally: make -C eq libeqapi.so from a bare checkout → OK; pytest python/eqlib/tests python/mcp-servers/eq_mcp/tests --forked --timeout=120 --timeout-method=signal → 125 passed, 3 skipped on macOS).

Both items are in as asked and verified: every WRITE(21) after the first and the CLOSE carry ERR=, label 900 closes the unit, unit 21 is closed on every path; the length comparison is strictly "file shorter than what the unit wrote", so on a runtime that reports the on-disk size it degrades to a no-op and cannot false-fail; EQSAVE is argument-less again and eq/eqmenu.f90 / tr/f77/trfile.f are identical to develop; EQ_READ is unchanged from the first round. Your MODEFW-after-tot_init rebuttal is right: the last init in tot_api_init is wr_api_init, which calls EQINIT after pl_init, so MODEFW is 0 there; my note came from reading the init list without the wr step. Thanks for taking the other seven.

Approving. Notes for a follow-up or a later commit, none blocking:

  • handle_save sets MODEFW to 3 / 0 and leaves it there; one clause in the tool docstring that the value persists would do.
  • The descriptor that a short-file save leaves open (your measurement, now in the docstring) is libgfortran's, not something EQSAVE_IERR can release; recorded as a documented limit.
  • eq/eqfile.f90 comment: one sentence that the size comparison's usefulness depends on the runtime reporting the unit's written length (another runtime makes it a no-op, not a failure) would save a future reader a measurement.
  • test_save.py assert getsize == limit assumes one flush at CLOSE (default 128 KiB buffer); <= limit also holds under GFORTRAN_UNFORMATTED_BUFFER_SIZE overrides.
  • _encode_name's docstring says 79 bytes where the constant, the README and the test say 80; pre-existing on develop, not this PR.

For the maintainer: this PR changes eq/eqfile.f90 (new EQSAVE_IERR, EQ_READ error order), which is upstream eq Fortran; the lead should see it, before or after merge per our rule.

@k-yoshimi
k-yoshimi merged commit 9aaeec2 into k-yoshimi:develop Oct 17, 2026
3 checks passed
HengyuLi-Ozaki-lab added a commit to HengyuLi-Ozaki-lab/task that referenced this pull request Oct 18, 2026
develop gained k-yoshimi#256, k-yoshimi#257, k-yoshimi#258, k-yoshimi#259, k-yoshimi#261, k-yoshimi#263 and k-yoshimi#264 since the
last merge (f41fe5b).

Two conflicts:
- CHANGELOG.md: this branch's entry and develop's new entries were
  added at the same place under "### Added"; both kept.
- fp/fp_api.f90, the USE lists of the module: this branch reads NTG1,
  TVOLR and the six P*T arrays from fpcomm, k-yoshimi#259 imports
  fp_param_generation; both kept.

And one change that no line conflict shows. k-yoshimi#259 replaced the clear of
g_prepared in fp_api_set_param by a count of registry sets
(fp_param_generation, compared by fp_run with g_prep_generation). The
global scalars of fp_get_state were gated on g_prepared alone, so after
the merge a set no longer zeroed them, against what this branch
documents and tests ("zero ... and again after a set_param until the
next run that is made"). The gate is now "prepared, and no set since
the last prep" (g_prepared .AND. g_prep_generation ==
fp_param_generation()), which also covers sets through tot's "fp:"
dispatcher.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HengyuLi-Ozaki-lab added a commit to HengyuLi-Ozaki-lab/task that referenced this pull request Oct 18, 2026
develop gained k-yoshimi#242, k-yoshimi#243, k-yoshimi#248, k-yoshimi#249, k-yoshimi#251, k-yoshimi#254, k-yoshimi#255, k-yoshimi#256, k-yoshimi#257,
k-yoshimi#258, k-yoshimi#259, k-yoshimi#261, k-yoshimi#263 and k-yoshimi#264 since this branch left it.

One conflict, in python/eqlib/_ffi.py, python/fplib/_ffi.py and
python/trlib/_ffi.py alike: the end of load_library. k-yoshimi#261 (k-yoshimi#246) checks
the state layout of the library just loaded and raises
<Mod>libAbiError on a mismatch; this branch closes the directory it
registered with os.add_dll_directory when the load fails. The check is
now inside the block that cleans up, so a library refused for its
layout does not leave its directory in the process-wide DLL search set
either (or an entry in _DLL_DIR_HANDLES). Order as on develop: the
layout check, then the prototypes.

With the check on the load path, the cases of test_platform_libname.py
that fake the loaded library (ctypes.CDLL patched) were refused by it:
24 of 39 failed. Each of the three files now has an autouse fixture that
patches check_state_layout out for them (the check has its own cases in
test_ffi.py, TestLibraryLayout), and one new case per module puts a
check back that raises AbiMismatchError (with the prototypes passing,
so that only the check can refuse the load) and asserts
<Mod>libAbiError, the directory handle closed and nothing registered;
with the check moved after the cleanup block, that case fails (measured
on eq).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HengyuLi-Ozaki-lab added a commit to HengyuLi-Ozaki-lab/task that referenced this pull request Oct 21, 2026
No line conflict. What the merge changes for this branch without one:
- k-yoshimi#233 put a scalars object into fp_mcp's state schema, zero after a
  set until the next run; set_param_str opens that window too, which
  the schema text did not say yet (next commit).
- k-yoshimi#256 brought eq_mcp's save tool, which this branch's docs still
  called future, after k-yoshimi#226 (fixed in a later commit).
- k-yoshimi#232 and k-yoshimi#257 moved fp_mcp's fd isolation into python/_fd_isolation;
  this branch's server.py keeps it.
- k-yoshimi#261 added the layout queries fp_state_abi_version and fp_state_size
  to libfpapi.so; fplib refuses a library without them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HengyuLi-Ozaki-lab added a commit to HengyuLi-Ozaki-lab/task that referenced this pull request Oct 21, 2026
With develop in this branch, a few sentences no longer hold:
- eq_mcp's save tool is in develop (k-yoshimi#256), so "once k-yoshimi#226 lands" goes
  from the module docstring, the set_param_str tool docstring and
  README section 8.4.
- The get_state tool docstring lists the returned keys and now names
  scalars too (the first LOW of the k-yoshimi#233 approval, left for this PR).
- libfpapi.so exports eight C symbols since k-yoshimi#261: README section 2 now
  names the six entry points and the two layout queries fplib checks.
- The Sphinx FP MCP pages (en, ja) still said nine tools and that the
  string setter is not reachable through MCP; they now list ten tools,
  with a set_param_str row, and point at README section 8.4.
- fp_mcp/__init__.py's docstring said nine tools.
- The comment above SupportedValue says that this server exposes the
  string setter as the set_param_str tool, as suggested on k-yoshimi#231.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HengyuLi-Ozaki-lab added a commit to HengyuLi-Ozaki-lab/task that referenced this pull request Oct 21, 2026
develop now carries the pieces split out of this branch (k-yoshimi#256 eq_save,
k-yoshimi#257 the MCP fd isolation, k-yoshimi#265 the mcp pin) and k-yoshimi#232, k-yoshimi#233, k-yoshimi#240, k-yoshimi#242,
k-yoshimi#243, k-yoshimi#248, k-yoshimi#249, k-yoshimi#251, k-yoshimi#254, k-yoshimi#255, k-yoshimi#258, k-yoshimi#259, k-yoshimi#261, k-yoshimi#263, k-yoshimi#264. 43
files conflicted; each was resolved as follows.

- Taken from develop (36 files): every file where this branch only
  changed the mcp pin to "mcp>=0.9,<2" (develop has ">=1.2,<2", k-yoshimi#265):
  the seven pyproject.toml, six READMEs, the fp, ti, tot, wr and wrx
  servers, the Sphinx MCP pages, the trlib presentation and its
  builder, the 2026-04-18 MCP plan; and the eq_save pieces now in
  develop in their reviewed form: eq/eq_api.f90, eq/eq_api.h,
  python/eqlib/tests/test_save.py, eq_mcp/tests/test_save_tool.py; and
  tr_mcp/server.py, whose changes here were this branch's own fd
  isolation (develop's _fd_isolation replaces it) and the os._exit(0)
  at the end of main().
- The os._exit(0) that ended main() of tr_mcp and eq_mcp on this branch
  (to skip the Python teardown, cited in k-yoshimi#227 item 5) is gone with
  them: develop's main() returns 0, as approved in k-yoshimi#257. The abort it
  avoided was a double DEALLOCATE, which this branch fixed in
  tr/trcomm.f90 (DEALLOCATE_TRCOMM returns when nothing is allocated);
  that fix stays, merged without a conflict.
- eq/eqfile.f90: develop's (EQSAVE without an argument, EQSAVE_IERR
  doing the work, and its kept EQDSKR error), plus this branch's
  MODELG=25 in EQ_READ's EQDSK branch (k-yoshimi#228-2). eq/eqmenu.f90 merged
  without a conflict but kept this branch's "CALL EQSAVE(IERR)"; it is
  develop's "CALL EQSAVE" again, as EQSAVE takes no argument.
- python/eqlib/_ffi.py, eqlib.py, tests/test_eqlib.py: develop's, plus
  this branch's get_psi_rz (the eq_common_get_psi_rz_ prototype, the
  Eq.get_psi_rz method, its closed-handle assertion).
- eq_mcp/server.py: develop's (the save tool of k-yoshimi#256, the shared fd
  isolation), plus this branch's get_psi_rz handler and tool and its
  --print-tools entry (13 tools). eq_mcp/tests/test_server.py: this
  branch's (13 tools with save and get_psi_rz), which contains
  develop's change.
- python/mcp-servers/eq_mcp/tests/test_import_isolation.py (this branch
  only, no conflict) is removed: it pinned this branch's own isolation
  helper (_install_fd_isolation, _FD_ISOLATION_INSTALLED in eq_mcp and
  tr_mcp), which develop's python/_fd_isolation.py replaces; develop's
  tests/test_fd_isolation.py of each of the seven servers checks the
  same contract (an import leaves fd 1 and sys.stdout alone).
- .github/workflows/python-tests.yml: develop's mcp pin; this branch's
  numpy install (the get_psi_rz tests import numpy) and ../task symlink
  step kept.
- CHANGELOG.md did not conflict. The three k-yoshimi#228 entries this branch held
  back in d8b6e4d are added to [Unreleased] Fixed, after its first
  entry, where no other open pull request inserts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants