Surface convergence quality in the pseudosteady limit study - #147
Surface convergence quality in the pseudosteady limit study#147bernalde wants to merge 7 commits into
Conversation
The study decided every rung from the termination alone, and printed the solver message only when a rung failed. Every converged rung of the recorded IPOPT baseline is acceptable-level, so the report stated `optimal/ok converged` eleven times while suppressing the only field that said otherwise. RungResult now carries `convergence_quality` from the extracted metadata and both report lines print it, so the ladder states which tolerance each rung met. Settle the cross-solver mapping the POUNCE comparison needs. Measured through the `ipopt` ASL interface on one NLP: IPOPT writes `Optimal Solution Found` and `Solved To Acceptable Level.`, POUNCE 0.9.0 writes `SolveSucceeded` and `SolvedToAcceptableLevel` -- the same ApplicationReturnStatus vocabulary in enum spelling. Matching IPOPT's prose alone put every POUNCE solve in `unknown`, converged ones included, which would have left the field carrying no information in exactly the comparison it was added for. classify_convergence_quality now matches the status rather than the punctuation, so no per-solver table is needed; a solver outside the vocabulary still reads `unknown`. Refs #146.
A mutation battery found the per-rung line printed while the ladder is still solving to be the one report surface with no test: removing its quality column left the suite green. It was unreachable from a test because it lived in a closure inside main(), so extract format_rung_progress() beside format_results() and pin both the converged and no-endpoint cases. Refs #146.
Re-run on the same IPOPT line the baseline was taken with -- opt.version() reports 3.14.16.0, matching the recorded solver_version -- so the artifact gains the schema field without a silent provenance change. Verified field by field against the previous artifact: solver, scaling method and discretization blocks identical; both ladders stop at the same rung (7 and 6); every pre-existing per-rung value byte-identical. The only delta is the added convergence_quality, which reads accepted_at_acceptable_tol on all 11 converged rungs and unknown on the two error rungs -- the conflation this issue reported, now stated by the artifact itself. Refs #146.
The tutorial for this study reported the committed baseline through a `termination` column reading `optimal` on all 11 converged rungs, and its live rerun printed `(converged)` per rung. Both are the conflation this issue is about, on the most reader-facing surface of the three. The baseline table now shows `convergence_quality` instead of the termination condition: every converged rung reports `optimal` by construction, so that column carried no information while implying these solves reached `tol`. Section 2 gains a short paragraph on why acceptable-level is the expected outcome here, placed before the column's first appearance; the live rerun prints the same label and refers back to it. `docs/examples/*_output.ipynb` is gitignored, so no executed artifact is committed; the notebook-execution test regenerates it and passes. Refs #146.
|
Check out this pull request on See visual diffs & provide feedback on Jupyter Notebooks. Powered by ReviewNB |
bernalde
left a comment
There was a problem hiding this comment.
Reviewed the full changed-file set at d6681ef (10 files, verified against the
paginated pull-files endpoint). The mechanism is right and the classifier change is
well-scoped; one finding blocks, because the PR ships five new statements about a
tracked artifact and pins none of them.
Blocking
The new convergence-level claims about the committed baseline are unpinned
Five artifacts now assert that every converged rung of
benchmarks/results/pseudosteady_limit/ipopt.json is accepted_at_acceptable_tol:
examples/pseudosteady_limit_study.pymodule docstring;examples/pseudosteady_limit_study.py,RungResultdocstring;benchmarks/README.md;docs/how-to-guides.md;docs/examples/pseudosteady_frozen_layer.ipynb, section 2.
All five are true at this head — I verified the regenerated artifact reads
accepted_at_acceptable_tol on all 11 converged rungs. Nothing enforces them.
That matters because regeneration is a routine, instructed operation, not a rare
one: benchmarks/README.md says to regenerate "when the models change, when the
solver or its version changes, or when adding a solver". A regeneration whose
rungs come back converged_to_tolerance would leave all five documents wrong
with nothing failing, which is the same defect class this PR exists to close —
a report describing the baseline as something it is not.
The repository already guards the sibling case and states the intent plainly.
tests/test_pseudosteady_frozen_layer.py::test_baseline_still_supports_the_documented_headline
pins the notebook's quoted -0.622% / -0.080% to the artifact, and the module
docstring says "regenerating the baseline without updating the notebook text must
fail loudly rather than ship stale teaching numbers". This PR adds claims of
exactly that kind and does not extend the guard.
Two concrete parts:
- Extend
tests/test_pseudosteady_frozen_layer.pywith an assertion that every
rung of the committed baseline withconverged: truecarries
convergence_quality == "accepted_at_acceptable_tol", and every non-converged
rung carries"unknown", naming the documents that go stale if it fires. That
is a fast-lane test over a committed file — no solver runs. - The notebook's own guard cell ("Hold the live rerun to the claim this notebook
teaches") assertsall(r.converged for r in rungs)but not the level, while
section 3 now claims the live rungs carry the same label as the baseline table.
Add the level to that assertion so the notebook holds its own new claim.
Nonblocking
One fixture hand-writes the quality it should classify
_rung was deliberately changed to derive convergence_quality from its own
message, with the comment "Classified rather than hard-coded so the fixture cannot
claim a quality its own message does not support". The one fixture that builds the
tighter level bypasses that: test_the_report_separates_the_two_convergence_levels
sets solver_message and convergence_quality independently, so a fixture whose
two fields disagree would still pass. They agree today, so nothing is wrong — but
build it through classify_convergence_quality like _rung does, so the
discipline holds on both sides of the comparison the test exists to make.
Question
Is folding the tutorial into this PR the scope you want?
The tutorial change is not in #146's acceptance criteria. The argument for it: it
is the same defect on the most reader-facing surface — the baseline table printed
termination: optimal on all 11 converged rungs, and swapping that column for the
recorded quality is strictly more informative, since termination_condition is
optimal for every converged rung by construction. The argument against: #146
names three artifacts and this is a fourth, and the tutorial merged only days ago
in #144. Splitting it costs a second PR and leaves the tutorial stating the thing
this PR is fixing everywhere else. My call was to fold it in; say if you would
rather it moved.
Checks I ran
Beyond reproducing the PR's own lanes, two checks the diff does not show:
Two-sided classifier sweep. The widened match could over-claim as easily as the
old one under-claimed, so I swept both implementations over all 20 IPOPT
ApplicationReturnStatus values in both spellings (39 messages). Exactly two
verdicts move, both intended:
Solve_Succeeded unknown -> converged_to_tolerance
Solved_To_Acceptable_Level unknown -> accepted_at_acceptable_tol
Every non-success status still reads unknown in both spellings, and every prose
message IPOPT actually writes classifies as before. No existing IPOPT-only result
changes label.
Baseline regeneration is a pure schema addition. Compared the regenerated
artifact field by field against its predecessor: solver, nlp_scaling_method and
discretization identical; both ladders stop at the same rung; every pre-existing
per-rung value unchanged. opt.version() reports 3.14.16.0, matching the recorded
solver_version, so #146's provenance concern does not arise.
Local suites at this head: ruff clean; 360 fast, 179 pyomo, 410 full non-Pyomo,
notebook lane 1 passed; mkdocs build clean. mypy fails on a numpy stub in this
environment identically at origin/main — pre-existing and advisory per AGENTS.md.
GitHub checks at this head: all 9 pass, including the Notebook lane, which
skips on drafts and only ran once the PR was marked ready. The four
branch-protection contexts (Static analysis, Fast SciPy lane, Full non-Pyomo
validation, Pyomo import and construction lane) are green.
Linked issue
Closes #146 is the right keyword: all four acceptance criteria are met at this
head, with criterion 4 answered by measurement rather than deferred. The POUNCE
baseline artifact correctly stays with #140.
Summary
- Blocking: 1
- Nonblocking: 1
- Question: 1
I would not merge this until the blocking issue above is addressed.
| `convergence_quality` is what separates a rung that met `tol` from one accepted | ||
| at `acceptable_tol`; Pyomo reports both as `optimal`, so the termination | ||
| condition cannot. Every converged rung of the IPOPT baseline reads | ||
| `accepted_at_acceptable_tol`. That is the expected outcome for this |
There was a problem hiding this comment.
Blocking — this sentence, and the four others like it in this PR, state a property of a tracked artifact that nothing enforces. This file's own regeneration instructions a few lines below make that a recurring risk rather than a one-off fact. See gh-review-pr:finding=unpinned-baseline-quality-claim in the review body for the two-part fix.
There was a problem hiding this comment.
Fixed in 384a6f2. tests/test_pseudosteady_frozen_layer.py::test_baseline_still_records_the_documented_convergence_level now requires every converged: true rung of this artifact to record accepted_at_acceptable_tol and every unsolved rung to record unknown, and its failure message names the four documents that go stale so a regeneration says what to update. Mutating one rung to converged_to_tolerance makes it fail, which is the outcome that shows it guards.
One correction: the review counted five artifacts by listing the study's module and RungResult docstrings separately; they are one file, so the guard names four paths. docs/reference.md was checked and does not make the claim.
| tight = dataclasses.replace( | ||
| _rung(1.0, 6.1865), | ||
| solver_message="Ipopt 3.14.16: Optimal Solution Found", | ||
| convergence_quality=paper_ocp.CONVERGED_TO_TOLERANCE, |
There was a problem hiding this comment.
Nonblocking — _rung derives the quality from its message so a fixture "cannot claim a quality its own message does not support"; this one sets the two independently. Build it with paper_ocp.classify_convergence_quality(...) on the same message so both sides of the comparison follow the same rule.
There was a problem hiding this comment.
Fixed in 384a6f2 — the tighter fixture now classifies from its own message, the rule _rung already followed, so neither side of the comparison can claim a quality its message does not support.
Addresses the #147 review. Blocking, unpinned-baseline-quality-claim: four documents state that every converged rung of the committed baseline is acceptable-level, and nothing enforced it. benchmarks/README.md instructs regenerating that artifact whenever the models or the solver version change, so a regeneration reaching `tol` would have left all four quietly wrong -- the same defect class #146 exists to close. Add test_baseline_still_records_the_documented_convergence_level beside the existing narrative guard, naming the documents that go stale in the failure message, and pin the recorded rung count so a truncated artifact cannot satisfy the loop. The notebook's own guard cell now holds the live rungs to the level section 2 claims for them, not just to converging. Nonblocking, fixture-hardcodes-quality: the one fixture building the tighter level set message and quality independently, bypassing the rule _rung follows. Classify it from its own message instead. Refs #146.
|
Addressed the review at Blocking:
|
| Mutation | Result |
|---|---|
a converged rung regenerates at tol |
caught |
| an unsolved rung claims a tolerance | caught |
| the artifact is truncated by one rung | caught |
| notebook guard demands a level the live rungs cannot have | caught |
Checks at this head
ruff clean; 361 fast, 179 pyomo, 1 notebook, mkdocs build clean. All 9 GitHub checks pass, including the Notebook lane. mypy still fails on a numpy stub in this environment identically at origin/main — pre-existing and advisory per AGENTS.md.
bernalde
left a comment
There was a problem hiding this comment.
Re-review of 384a6f2, after the prior review at d6681ef.
The earlier baseline-claim guard and fixture-consistency findings are addressed: the committed baseline is now pinned to the convergence level the documents state, the live notebook guard checks that level, and the tighter fixture derives its quality from its own message. Keeping the tutorial in this PR remains the right scope call.
One new blocking finding is posted inline on the notebook source. The implementation and fresh notebook execution produce the intended labels, but the tracked outputs were not refreshed, so the built reader-facing page still publishes the old termination: optimal and (converged) output.
Checks
python -m ruff check lyopronto tests examples main.py— passed.python -m pytest tests/test_pyomo_models/test_paper_ocp.py tests/test_pseudosteady_limit_study.py tests/test_pseudosteady_frozen_layer.py -n 0 -q -m "not notebook"— 80 passed, 1 deselected.python -m pytest tests/test_pseudosteady_frozen_layer.py -n 0 -q -m notebook— 1 passed, 4 deselected; the fresh output containsaccepted_at_acceptable_tolas intended.mkdocs build— completed; inspecting the generated HTML reproduced the stale committed-output finding.- Direct POUNCE 0.9.0 probes returned
SolveSucceededattolandSolvedToAcceptableLevelatacceptable_tol; both classify correctly at this head. - All nine GitHub checks currently pass at
384a6f2, including the solver lane that executes this notebook.
Summary
- Blocking: 1 inline
- Nonblocking: 0
- Questions: 0
Closes #146 remains appropriate once the tracked notebook output is refreshed. I would not merge this until the blocking issue above is addressed.
| " # Not the termination condition: every converged rung reports `optimal`\n", | ||
| " # by construction, so that column would say nothing while implying these\n", | ||
| " # solves reached `tol`. The recorded quality is what separates them.\n", | ||
| " print(f\" {'f':>6} {'drying time [hr]':>18} {'convergence':>27}\")\n", |
There was a problem hiding this comment.
Blocking — these cell sources now print convergence quality, but the tracked outputs were not refreshed. The committed cell-8 output still renders a termination column containing optimal, and cell 12 still renders (converged). I confirmed that mkdocs build publishes both stale outputs, so the reader-facing notebook still shows the exact conflation this PR fixes. A fresh papermill run produces the correct accepted_at_acceptable_tol output, but its ignored *_output.ipynb does not update this tracked notebook. Re-execute and commit the outputs here, correct the PR body's claim that no executed artifact is committed, and extend test_notebook_ships_executed_outputs (or add a focused guard) to require the committed outputs to carry the quality rather than the stale termination/converged rendering.
There was a problem hiding this comment.
Confirmed and fixed in f6b8d51 (outputs) and d6dd8cc (guard). You are right, and my earlier check was aimed at the wrong file: I verified pseudosteady_frozen_layer_output.ipynb regenerated identically and concluded the notebook was fine, but that file is gitignored — the tracked notebook carries its own committed outputs, and those are what mkdocs publishes.
Regenerated with a no-parameter papermill run matching the committed metadata.papermill, verified outputs-only (every cell source byte-identical to 384a6f2; only cells 8 and 12 move). The built page now renders accepted_at_acceptable_tol 19 times and (converged) zero times. test_committed_outputs_render_the_convergence_level now fails if sources and committed outputs disagree — reintroducing the stale outputs makes it red, which is the check that was missing. PR body corrected too.
…ts sources Outputs only: no cell source changes in this commit, verified by comparing every cell's source against HEAD. Cells 8 and 12 are the only outputs that move. The tracked notebook commits its rendered outputs and mkdocs publishes them, so editing the cell sources in d6681ef did nothing for a reader: the published page kept showing a `termination` column of `optimal` and `(converged)` per rung -- the conflation of #146, still on the page under sources that had already been fixed. The gitignored `*_output.ipynb` that the notebook lane writes is a validation artifact, not the published one, which is why the lane stayed green throughout. Regenerated with a no-parameter papermill run, matching what the committed metadata.papermill records (parameters {}, repo-relative paths, no injected cell), then copied over the tracked file. Refs #146.
The stale rendering above survived a source edit, a full local suite, and nine green CI checks, because nothing compared the committed outputs with the sources that produce them. Assert that exactly two cells render the level and that no committed output still carries the pre-#146 form, and record the regeneration command in the docstring so the fix is not re-derived. Refs #146.
|
Addressed the stale-outputs review at Blocking: committed notebook outputs were stale — fixedThe finding is correct, and my earlier reasoning was wrong in a way worth stating plainly. I checked So editing the cell sources in Nothing caught it. The notebook lane passed, all nine checks passed, and the full local suite passed, because no check compared the committed outputs against the sources that produce them. Regenerated ( Guarded ( PR body correctedThe body claimed "no executed artifact is committed", which is what let this through. It now says the tracked notebook does commit its outputs, names the gitignored file as the validation artifact rather than the published one, and points at the new guard. The stale local test counts were refreshed at this head. The same wrong sentence is in Mutation evidenceProbed one defect class at a time; "caught" means the mutation made the guard fail, which is the intended, reassuring outcome:
CIAll 9 checks green at Local at this head: ruff clean; 362 fast, 179 pyomo, 412 full non-Pyomo, 6 notebook; Still openThe |
Summary
paper_ocphas recordedmetadata["convergence_quality"]since #145, but nothing read it, and the report that most needed it said the opposite.examples/pseudosteady_limit_study.pydecided each rung from the termination alone and printed the solver message only on failure, so all 11 converged rungs of the committed IPOPT baseline — every one of themSolved To Acceptable Level— printed asoptimal/ok converged.Each rung now carries and prints the convergence level, the baseline is regenerated on the same IPOPT line, and the cross-solver mapping question is settled by measurement rather than deferred.
Closes #146.
Acceptance criteria
RungResultcarries the convergence quality and the ladder report prints it per rung. The field is populated from the extracted metadata rather than re-matching solver text. Both per-rung surfaces print it: theformat_resultstable (quality=…) and the line streamed while the ladder is still solving.benchmarks/results/pseudosteady_limit/ipopt.jsonregenerated consistently, with any solver-version change stated rather than silent. Regenerated on IPOPT 3.14.16 —opt.version()reports3.14.16.0, matching the recordedsolver_version, so there is no provenance change to state. Verified field by field against the previous artifact: thesolver,nlp_scaling_methodanddiscretizationblocks are identical; both ladders stop at the same rung (7 and 6); every pre-existing per-rung value is unchanged. The only delta is the added field.tests/test_pseudosteady_limit_study.pyasserts an acceptable-level rung is reported as such rather than only asconverged. Plus a test that a tolerance-converged rung and an acceptable-level one — bothoptimal/ok— do not read alike, and that a rung with no solve claims no tolerance.classify_convergence_qualityneeds a per-solver mapping. Settled by measurement; see below.The cross-solver mapping: measured, not deferred
POUNCE was not installed when #145 was written, so what it puts in
solver.messagewas unverified. Measured here on one NLP through theipoptASL interface, forcing each outcome withtol/acceptable_tol/acceptable_iter:solver.messagetolIpopt 3.14.16: Optimal Solution Foundconverged_to_toleranceacceptable_tolIpopt 3.14.16: Solved To Acceptable Level.accepted_at_acceptable_toltolPOUNCE 0.9.0: SolveSucceededunknownconverged_to_toleranceacceptable_tolPOUNCE 0.9.0: SolvedToAcceptableLevelunknownaccepted_at_acceptable_tolSo
unknownwas not an acceptable answer: every POUNCE solve landed there, fully converged ones included, which would have left the field carrying no information in exactly the comparison it was added for.Neither is a per-solver table the right shape. POUNCE is an IPOPT port and reuses IPOPT's
ApplicationReturnStatusvocabulary — it writes the enum names where IPOPT writes prose. One vocabulary, two spellings.classify_convergence_qualitynow matches the status with punctuation and digits dropped, so both binaries classify through one path with no solver identity threaded in. A solver outside that vocabulary still readsunknown, which is the honest answer rather than a mapping guessed from unseen text.This changes only the label; the success gate is untouched and still accepts both levels, for the reasons in
paper_ocp._is_successful_termination.Scope note: the tutorial notebook
Not named in the issue, but the same defect on the most reader-facing surface.
docs/examples/pseudosteady_frozen_layer.ipynbteaches this study and reports the committed baseline through aterminationcolumn readingoptimalon all 11 converged rungs, with its live rerun printing(converged)per rung. The table now showsconvergence_qualityinstead — every converged rung reportsoptimalby construction, so that column said nothing while implying these solves reachedtol— and section 2 explains why acceptable-level is expected here, before the column's first appearance.This notebook does commit its rendered outputs, and
mkdocspublishes them — the gitignoreddocs/examples/*_output.ipynbthat the notebook lane writes is a validation artifact, not the published one. An earlier revision of this PR changed the cell sources without re-executing, so the published page kept showing the pre-#146 rendering under corrected sources; the notebook lane stayed green throughout because it never compares the two. The tracked notebook is now regenerated with a no-parameter papermill run (matching what its committedmetadata.papermillrecords) andtest_committed_outputs_render_the_convergence_levelfails if the committed outputs and their sources ever disagree again.The POUNCE baseline artifact remains out of scope and stays with #140, which is waiting on a tagged release containing jkitchin/pounce#519. What this PR settles is the mapping that baseline will need when it lands, which is what #146 asked for.
Validation
Interpreter:
/home/bernalde/.local/bin/python(Python 3.13, Pyomo 6.9.5, IPOPT 3.14.16, POUNCE 0.9.0).ruff check lyopronto tests examples main.pypytest tests/ -n auto -m "not slow and not notebook and not pyomo"pytest tests/ -n auto -m pyomo./run_local_ci.sh fullpytest tests/ -n 0 -m notebookmkdocs buildpython -m mypy lyoprontoorigin/main— pre-existing and advisory perAGENTS.mdMutation evidence
The new tests were mutation-proven, one probe per defect class, each against an unfiltered run of both changed test modules:
format_resultsomits the quality columnSolveSucceededmapped to the looser levelThe streamed-line probe survived on the first pass: that line lived in a closure inside
main()and no test could reach it.format_rung_progress()was extracted besideformat_results()and pinned, which is what the second and third rows above verify.Branch Hygiene
Base
main, branched fromorigin/mainatc732848. Not stacked; no prerequisite PRs. No other open PR touches these files (there are none open).