Surface convergence quality in the pseudosteady limit study - #147
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.
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.
…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 |
bernalde
left a comment
There was a problem hiding this comment.
Follow-up review at d6dd8cc (prior review at 384a6f2). Scope: the two address commits — f6b8d51 (notebook regenerated) and d6dd8cc (outputs-to-sources guard) — plus re-verification of the full changed-file set. Outside docs/examples/pseudosteady_frozen_layer.ipynb and tests/test_pseudosteady_frozen_layer.py, every changed file is byte-identical to the previously reviewed head, so the earlier rounds' conclusions for those files carry forward.
Prior findings
Blocking, round 2 (stale committed notebook outputs) — resolved, verified past the claim.
- Cell sources are byte-identical to
384a6f2; only cells 8 and 12 changed, outputs-only; execution counts unchanged; the papermill metadata delta is timing-only, recording a realparameters: {}run on 2026-08-12 — exactly the regeneration command the guard's docstring pins. - A fresh no-parameter papermill run reproduces the committed outputs byte-identically in all 9 code cells. The committed rendering is what the sources produce, not a hand edit.
- The built site's tracked page renders
accepted_at_acceptable_tol19 times and(converged)zero times; the oneterminationon the page is the explanatory source comment, not an output. - The new guard fails against the
384a6f2notebook — the exact regression class, sources edited without re-execution — and passes at head. It reads the committed file only, so it runs in the fast lane with no solver.
Blocking, round 1 (unpinned-baseline-quality-claim) — still in place at this head. test_baseline_still_records_the_documented_convergence_level pins all 13 rungs and passes in the fast lane.
Nonblocking, round 1 (fixture-hardcodes-quality) — still in place. The tight-side fixture classifies through classify_convergence_quality rather than hardcoding the level.
Question, round 1 (notebook-scope-call) — answered: keep the tutorial fix folded in. Splitting it out would leave main publishing the pre-#146 rendering until a second PR landed, and the folded-in fix is now both verified by re-execution and guarded by test_committed_outputs_render_the_convergence_level. No further action.
New finding
Nonblocking: two coordination claims in the body have expired since they were written.
- Branch Hygiene says "No other open PR touches these files (there are none open)." #148 is open, is based on this PR's head branch, and shares
benchmarks/README.md,examples/README.md, andtests/test_pyomo_models/test_paper_ocp.py. - The scope note says #140 "is waiting on a tagged release containing jkitchin/pounce#519." POUNCE v0.10.0 was tagged on 2026-08-11, and #148 — stacked on this branch — pins it and carries the POUNCE comparison work, so the condition that sentence hedged on has matured.
Neither affects the code. Record the corrected coordination state in a top-level comment (or a body update): #148 stacks on this branch and is to be retargeted to main after this PR merges, as its own body already instructs.
Validation at d6dd8cc
| Check | Result |
|---|---|
ruff check lyopronto tests examples main.py |
passed |
pytest -n auto -m "not slow and not notebook and not pyomo" |
362 passed, matching the body |
tests/test_pseudosteady_frozen_layer.py, including the papermill+IPOPT execution test |
6 passed |
| no-parameter papermill re-execution diffed against committed outputs | byte-identical, 9/9 code cells |
mkdocs build and tracked-page grep |
clean build; 19 renderings of the level, zero stale forms |
| guard probed against the pre-regeneration notebook | fails as intended, passes at head |
| GitHub checks at this head | 9/9 pass |
The Pyomo lane (179) and full non-Pyomo validation (412) are carried forward from the round-2 review: the delta since 384a6f2 is byte-inert outside the two files above, and both lanes are green in CI at this head.
Summary
Blocking: 0. Nonblocking: 1 (stale coordination claims, above). Questions: 0 — the round-1 question is answered above. The base is current (merge-base equals origin/main at c732848) and the merge state is CLEAN. Posted as a COMMENT because this account authored the PR; there are no blocking findings, and the change is merge-ready with the coordination note as the one piece of cleanup.
|
Coordination update addressing the round-3 review's nonblocking finding ( Two body statements were true when written and have since expired. Current state, superseding them:
Nothing else in the body is superseded. No feedback was declined; the round-1 question was answered in the round-3 review (the tutorial fix stays folded in). Remaining step: human review and merge decision. |
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).