pounce-algorithm 0.10.0

Algorithm-side core for POUNCE (port of Ipopt's src/Algorithm/): IteratesVector, IpoptData, CalculatedQuantities, KKT solvers, line search, mu update, conv check, initializer, IpoptAlg main loop, AlgBuilder.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
//! Optimal-error convergence check — port of
//! `Algorithm/IpOptErrorConvCheck.{hpp,cpp}`.
//!
//! Tolerance state machine over `(nlp_err, iter_count)` plus
//! per-component infeasibilities pulled directly from
//! [`IpoptCalculatedQuantities`]. The scalar
//! [`Self::check_convergence`] entry point only gates on
//! `nlp_err <= tol` (matching upstream when the per-component
//! tolerances are at their `+∞` sentinels); the state-aware
//! [`Self::check_convergence_with_state`] adds the
//! `dual_inf_tol` / `constr_viol_tol` / `compl_inf_tol` gates that
//! mirror upstream `OptimalityErrorConvergenceCheck::CheckConvergence`.

use crate::conv_check::r#trait::{ConvCheck, ConvergenceStatus};
use crate::ipopt_cq::IpoptCqHandle;
use crate::ipopt_data::IpoptDataHandle;
use pounce_common::types::{Index, Number};

pub struct OptErrorConvCheck {
    pub tol: Number,
    pub dual_inf_tol: Number,
    pub constr_viol_tol: Number,
    pub compl_inf_tol: Number,
    pub acceptable_tol: Number,
    pub acceptable_dual_inf_tol: Number,
    pub acceptable_constr_viol_tol: Number,
    pub acceptable_compl_inf_tol: Number,
    pub acceptable_obj_change_tol: Number,
    pub acceptable_iter: Index,
    pub max_iter: Index,
    pub max_cpu_time: Number,
    pub max_wall_time: Number,
    pub acceptable_count: Index,
    /// Objective value at the last iterate the main loop stashed via
    /// `set_curr_acceptable_obj`. Used by the
    /// `acceptable_obj_change_tol` cross-check. `None` until an
    /// acceptable point has been recorded.
    pub last_acceptable_obj: Option<Number>,
    /// Tolerance on the scaled infeasibility stationarity
    /// `‖Jᵀc‖/max(1,‖c‖)`. An iterate counts toward the infeasibility
    /// streak when this ratio is at or below this value while the
    /// constraint violation stays bounded away from zero. Rapid
    /// infeasibility detection is disabled when this is non-positive.
    pub infeas_stationarity_tol: Number,
    /// Multiple of `constr_viol_tol` the constraint violation must
    /// exceed before an iterate can count as infeasible-stationary —
    /// keeps detection from firing on nearly-feasible flat spots. Floored
    /// at [`MIN_INFEAS_VIOL_FLOOR`]; see
    /// [`OptErrorConvCheck::absolute_viol_threshold`].
    pub infeas_viol_kappa: Number,
    /// Consecutive infeasible-stationary iterations required before
    /// terminating with `LocallyInfeasible`. Non-positive disables
    /// rapid infeasibility detection.
    pub infeas_max_streak: Index,
    /// Running count of consecutive infeasible-stationary iterations.
    pub infeas_streak: Index,
    /// Objective-scale floor below which a strict certificate is refused
    /// while the *unscaled* KKT error is still above `acceptable_tol`
    /// (gh #200). See [`certificate_masked`]. `0` disables the mechanism
    /// entirely, restoring bit-for-bit upstream-Ipopt behaviour.
    pub obj_scale_certificate_threshold: Number,
    /// Safety factor on the per-row noise floor the **strict** gate judges the
    /// primal term against (gh #528). `0` disables the floor entirely,
    /// restoring upstream Ipopt's bare-absolute primal residual.
    pub primal_noise_floor_kappa: Number,
    /// Fraction of `acceptable_tol` the KKT error — and, relative to the
    /// objective's own size, the objective — may drift across the
    /// acceptable-level streak's window while the streak still counts as
    /// *settled* (gh #533). See [`Self::streak_has_flattened`]. `0` disables
    /// the progress test, leaving acceptable-level termination the bare
    /// consecutive-count criterion upstream Ipopt uses.
    pub acceptable_progress_kappa: Number,
    /// Trailing `(nlp_err, f)` samples of the current acceptable-level streak,
    /// oldest first, at most [`Self::progress_window_len`] entries. Cleared
    /// whenever the streak breaks — the window describes *this* streak.
    pub acceptable_window: std::collections::VecDeque<(Number, Number)>,
    /// Acceptable-level terminations the gh #533 progress test has refused so
    /// far this solve. Bounded by [`ACCEPTABLE_PROGRESS_MAX_REFUSALS`], past
    /// which the test stands aside and the streak terminates as it would
    /// without it.
    pub acceptable_progress_refusals: Index,
    /// Safety factor on the scale-relative floor the **strict** gate judges
    /// `dual_inf` against (gh #532); see [`Self::dual_inf_bound`]. `0` disables
    /// the floor, restoring upstream Ipopt's bare-absolute `dual_inf_tol`.
    pub dual_inf_scale_kappa: Number,
    /// Whether the gh #532 scale-relative dual floor has already been reported
    /// this solve. Diagnostic only — the certificate below carries a dual
    /// infeasibility above `dual_inf_tol`, which is worth saying once and not
    /// once per iteration.
    pub dual_floor_reported: bool,
    /// Whether a masked **strict** certificate was ever refused this solve.
    pub veto_fired: bool,
    /// Whether a masked **acceptable-level** termination was ever refused.
    ///
    /// Tracked separately because the two refusals must be undone differently:
    /// a refused strict certificate restores as `Success`, a refused
    /// acceptable-level one as `StopAtAcceptablePoint`. Conflating them would
    /// either over-claim a status or, as originally written, leave the
    /// acceptable-level refusal with no safety net at all.
    ///
    /// Set by **both** refusal arms — the gh #200 masked-scale veto and the
    /// gh #533 progress test — because both need the same undo. What the
    /// masked veto's own iteration budget counts is
    /// [`Self::masked_acceptable_veto_fired`].
    pub acceptable_veto_fired: bool,
    /// Whether the *masked-scale* (gh #200) arm specifically refused an
    /// acceptable-level termination.
    ///
    /// [`VETO_MAX_EXTRA_ITERS`] is the masked veto's budget, so only the masked
    /// arms may spend it. Counting the gh #533 progress refusals against it too
    /// would silently disarm the masked veto 60 iterations into any solve whose
    /// acceptable streak was progress-refused — a different mechanism's bug
    /// coming back for reasons having nothing to do with objective scaling.
    pub masked_acceptable_veto_fired: bool,
    /// Iterations spent since the veto first refused a certificate.
    ///
    /// The veto is a bet that continuing reaches a better point. Some problems
    /// never let it pay off — an unscaled error pinned above `acceptable_tol`
    /// by an unbounded direction keeps the veto engaged until `max_iter`,
    /// turning a 40-iteration solve into a 300-iteration one for nothing. Past
    /// [`VETO_MAX_EXTRA_ITERS`] the bet is called off and the run is allowed to
    /// terminate normally; correctness does not depend on the cap, because the
    /// refused certificate is restored either way.
    pub veto_extra_iters: Index,
    /// Iterations on which the scale-relative feasibility veto blocked a
    /// certificate (strict or acceptable) that the absolute tolerances had
    /// passed. Bounded by [`VETO_MAX_EXTRA_ITERS`]; past the budget the veto
    /// disengages and the run terminates as it would have without it, so the
    /// worst case is a bounded number of extra iterations, never a lost
    /// verdict. See [`Self::relative_viol_threshold`].
    pub rel_infeas_extra_iters: Index,
    /// Relative primal infeasibility at the previous
    /// [`Self::note_infeasible_stationary`] call — the progress signal for the
    /// relative arm's streak (see that method). `NAN` until first set, which
    /// compares as "not improving" and lets the first iterate count.
    pub prev_rel_viol: Number,
}

/// How many iterations the veto may spend before its bet is called off.
///
/// Generous relative to what a successful rescue costs — the reported quartics
/// reach the true minimum in 11-15 extra iterations — but bounded, so a veto
/// that can never lift (an unscaled error pinned above `acceptable_tol` by an
/// unbounded direction) cannot run to `max_iter`. Correctness does not rest on
/// this number: whatever happens after the budget is spent, the refused
/// certificate is still restored if the run ends without a better one.
const VETO_MAX_EXTRA_ITERS: Index = 60;

/// How many acceptable-level terminations the gh #533 progress test may refuse
/// before it stands aside for the rest of the solve.
///
/// The test is already self-limiting — it only refuses while the streak's own
/// window shows the solve still moving, and a solve that stops moving flattens
/// the window within `acceptable_iter` iterations — so this bounds only the
/// pathological case: a solve that wanders inside the acceptable band without
/// ever settling and without ever reaching `tol`. Left unbounded that solve
/// would run to `max_iter` (returning the refused point, so no *verdict* is
/// lost, but spending up to 3000 iterations to say what it could have said at
/// 40).
///
/// The number has to clear the widest measured rescue: `kissing` needed 447
/// iterations past the refusal (103 → 550) to reach its strict certificate, so
/// anything below that cannot fix the reported case. `1000` clears it with room
/// to spare and still stops well short of the default `max_iter = 3000`. Note
/// that only iterations on which a termination is actually *refused* are
/// counted, not every iteration after the first refusal — a streak broken by an
/// iterate outside the band costs nothing here.
const ACCEPTABLE_PROGRESS_MAX_REFUSALS: Index = 1000;

/// Longest trailing streak window the progress test will keep samples for.
///
/// The window is `acceptable_iter` long (the streak's own length), which is 15
/// by default. The cap exists because `acceptable_iter` is a user option with no
/// upper bound, and the window is a live allocation. Past the cap the test
/// judges flatness over the trailing `ACCEPTABLE_PROGRESS_WINDOW_MAX` iterates
/// of the streak instead of all of it — a strictly more permissive reading (a
/// shorter window can only contain less movement), so the cap can never make
/// the mechanism fire where the full window would not have.
const ACCEPTABLE_PROGRESS_WINDOW_MAX: usize = 256;

/// Smallest constraint violation rapid infeasibility detection will ever treat
/// as "bounded away from feasible" (gh #519).
///
/// Both arms of [`OptErrorConvCheck::is_infeasible_stationary`] scale their
/// violation floor with `constr_viol_tol`, which is a *feasibility* tolerance:
/// left unclamped, tightening it widens the set of points the detector is
/// willing to convict, so asking for a stricter feasibility standard makes the
/// solver more eager to answer "locally infeasible". That inversion is the bug
/// this floor exists to prevent — at `constr_viol_tol = 1e-6` the absolute arm's
/// floor fell to `1e-4` and @bernalde's `f=1` model (gh #505), plateaued at an
/// unscaled violation of `1.94e-4` with a scaled NLP error of `4.89e-10`, was
/// reported infeasible at iteration 27 instead of "Solved To Acceptable Level"
/// at 37. The flip tracked `100 · constr_viol_tol` to three significant figures.
///
/// `1e-2` is the default `acceptable_constr_viol_tol`, so the floor also states
/// the intended rule directly: never convict a point of infeasibility while its
/// violation sits inside the band the defaults call acceptable. The two forms
/// coincide out of the box, which is why the defect was invisible there.
///
/// Erring loose is the safe direction — a withheld verdict costs iterations and
/// ends at `MaxIterExceeded` or an acceptable point, while a fabricated one is
/// a wrong answer. `infeas_viol_kappa` still raises the floor above this; the
/// disable switch remains `infeas_stationarity_tol = 0` (or
/// `infeas_max_streak = 0`), not a floor small enough to never bind.
const MIN_INFEAS_VIOL_FLOOR: Number = 1e-2;

/// Is a passing strict certificate *masked* by an extreme objective scale
/// (gh #200)?
///
/// Gradient-based scaling picks `df = nlp_scaling_max_gradient / max‖∇f‖`,
/// floored at `nlp_scaling_min_value = 1e-8`. On a flat quartic the initial
/// gradient is enormous (`quartc`: ~4e12 → `df` pinned at the floor), and the
/// strict test then runs on the *scaled* aggregate. Because a quartic's
/// gradient vanishes cubically toward its minimum while `df` stays fixed at its
/// initial value, the scaled error crosses `tol` roughly 30% of the way in: the
/// solver certifies optimality at `quartc` objective 248.88 when the true
/// minimum is ~0, with an unscaled dual infeasibility of 0.84.
///
/// This predicate deliberately does **not** try to decide whether the stop is
/// genuinely false — it only asks whether the conditions that make a false stop
/// *possible* are present. Distinguishing a masked certificate from an honest
/// one at a small scale cannot be done from the residual magnitude: `meyer3`
/// sits at the same 1e-8 scale floor as `quartc` while being genuinely
/// converged, and the unscaled error is a *dimensional* quantity, so any
/// absolute cutoff separating them would move if the objective were rescaled —
/// precisely the sensitivity this bug is about. An earlier revision of this
/// work did exactly that (a 5e-2 bar fitted to the gap in one benchmark suite);
/// it is not defensible and was removed.
///
/// Instead the caller *tests* the hypothesis: it refuses to stop, continues,
/// and sees whether the iterates actually go anywhere. If they do, the stop was
/// false. If they do not, the certificate is honoured unchanged — so the
/// mechanism is never worse than not having it (see `terminate_vetoed_or`).
pub fn certificate_masked(
    obj_scale: Number,
    unscaled_err: Number,
    threshold: Number,
    acceptable_tol: Number,
) -> bool {
    // A non-positive threshold is the documented opt-out; NaN is treated the
    // same way rather than silently enabling the mechanism.
    if threshold.is_nan() || threshold <= 0.0 {
        return false;
    }
    // Magnitude, not signed value: a negative `obj_scaling_factor` (the
    // documented way to maximize) is trivially below any positive threshold,
    // which would arm this on every maximization regardless of scale.
    obj_scale.abs() < threshold && unscaled_err > acceptable_tol
}

impl Default for OptErrorConvCheck {
    fn default() -> Self {
        // Defaults from `IpOptErrorConvCheck.cpp:RegisterOptions`.
        Self {
            tol: 1e-8,
            dual_inf_tol: 1.0,
            constr_viol_tol: 1e-4,
            compl_inf_tol: 1e-4,
            acceptable_tol: 1e-6,
            acceptable_dual_inf_tol: 1e10,
            acceptable_constr_viol_tol: 1e-2,
            acceptable_compl_inf_tol: 1e-2,
            acceptable_obj_change_tol: 1e20,
            acceptable_iter: 15,
            max_iter: 3000,
            max_cpu_time: 1e6,
            max_wall_time: 1e6,
            acceptable_count: 0,
            last_acceptable_obj: None,
            infeas_stationarity_tol: 1e-8,
            infeas_viol_kappa: 1e2,
            infeas_max_streak: 5,
            infeas_streak: 0,
            // 1e-4 separates the falsely-certified problems (objective scale
            // pinned at the 1e-8 floor) from every recorded collateral case
            // (`hs1`/`hs38` at ~4e-2, the 19-problem list at ~1e-2). See
            // [`certificate_masked`].
            obj_scale_certificate_threshold: 1e-4,
            primal_noise_floor_kappa: 64.0,
            // A tenth of the acceptable band. See `streak_has_flattened` for
            // why the band is the right yardstick and why a tenth of it is the
            // conservative end of the range.
            acceptable_progress_kappa: 1e-1,
            acceptable_window: std::collections::VecDeque::new(),
            acceptable_progress_refusals: 0,
            dual_inf_scale_kappa: 1.0,
            dual_floor_reported: false,
            veto_fired: false,
            acceptable_veto_fired: false,
            masked_acceptable_veto_fired: false,
            veto_extra_iters: 0,
            rel_infeas_extra_iters: 0,
            prev_rel_viol: Number::NAN,
        }
    }
}

impl OptErrorConvCheck {
    pub fn new() -> Self {
        Self::default()
    }

    /// Pure helper for the per-component upstream gate. Returns `true`
    /// iff every supplied residual sits at or below its tolerance.
    /// Factored out so tests can exercise the gating logic without
    /// constructing a full `IpoptCq`.
    ///
    /// `dual_scale` is the magnitude of the terms `∇L` is assembled from
    /// ([`IpoptCalculatedQuantities::curr_unscaled_dual_infeasibility_scale_max`]),
    /// which sets the scale-relative floor under `dual_inf_tol` — see
    /// [`Self::dual_inf_bound`]. Pass `0` for the bare absolute bound.
    fn passes_component_tols(
        &self,
        overall: Number,
        dual_inf: Number,
        constr_viol: Number,
        compl_inf: Number,
        dual_scale: Number,
    ) -> bool {
        overall <= self.tol
            && dual_inf <= self.dual_inf_bound(dual_scale)
            && constr_viol <= self.constr_viol_tol
            && compl_inf <= self.compl_inf_tol
    }

    /// The bound the **strict** gate judges the unscaled dual infeasibility
    /// against: `max(dual_inf_tol, dual_inf_scale_kappa · tol · dual_scale)`
    /// (gh #532).
    ///
    /// `dual_inf_tol` is a bare absolute bound on a quantity the aggregate KKT
    /// error normalises. The aggregate's dual term is `‖∇L‖_∞ / s_d`, and `s_d`
    /// grows with the mean magnitude of the multipliers, so on a model whose
    /// gradients live at `1e10` the two are judging one quantity by two
    /// standards ten orders apart: Vanderbei's `orthrds2` reaches `s_d ≈ 1.6e10`
    /// with `‖∇L‖_∞ = 89.7`, an aggregate dual term of `5.6e-09` — comfortably
    /// inside the default `tol = 1e-8` — and the component gate refused it
    /// against `1.0`, so a solve stationary to nine digits exited
    /// `Solved_To_Acceptable_Level` holding the answer. `1.0` is a reasonable
    /// absolute bound when `‖∇f‖` is `O(1)`; it is meaningless when `‖∇f‖` is
    /// `1e10`, and the same LP with its objective multiplied by a positive
    /// constant — which changes no feasible point, no solution and no active
    /// set — crossed it.
    ///
    /// The floor is stated relative to the terms `∇L` is *made of*
    /// (`dual_scale`), not to `s_d`. Both remove the asymmetry the issue
    /// reports, but `s_d` is built from multiplier magnitudes alone and does not
    /// see `∇f`: a model with tiny constraint gradients and huge multipliers
    /// (`‖J‖ ~ 1e-12`, `‖y‖ ~ 1e12`, so every term of `∇L` is `O(1)`) has
    /// `s_d ~ 1e10` and would have its genuinely non-stationary residual
    /// forgiven — exactly the user-space drift the unscaled component gate was
    /// added for (pounce#173). `dual_scale` cannot be fooled that way, because
    /// `dual_inf / dual_scale` is the fraction of the terms that failed to
    /// cancel.
    ///
    /// So the relaxation only ever forgives a residual that is small *relative
    /// to the problem's own scale*, and it is bounded twice over: the aggregate
    /// `overall <= tol` gate still has to pass on the same iterate, and at the
    /// default `kappa = 1` the floor only rises above `dual_inf_tol` once
    /// `dual_scale` exceeds `dual_inf_tol / tol = 1e8`. A genuinely
    /// non-stationary point has `dual_inf ≈ dual_scale` (nothing cancelled) and
    /// is refused by eight orders of magnitude — `min -exp(x) s.t. x >= 0`
    /// reaching `inf_du = 8.8e+47` with `∇f = −8.8e47` stays refused, which is
    /// the case any such rule has to keep rejecting.
    ///
    /// A user who tightens `dual_inf_tol` below the floor is asking for an
    /// absolute standard the floor may override; `dual_inf_scale_kappa = 0`
    /// switches it off and restores upstream's bare comparison. Non-finite or
    /// non-positive scales are read as "nothing can be said", which is the
    /// absolute bound.
    fn dual_inf_bound(&self, dual_scale: Number) -> Number {
        if self.dual_inf_scale_kappa.is_nan()
            || self.dual_inf_scale_kappa <= 0.0
            || !dual_scale.is_finite()
            || dual_scale <= 0.0
        {
            return self.dual_inf_tol;
        }
        self.dual_inf_tol
            .max(self.dual_inf_scale_kappa * self.tol * dual_scale)
    }

    /// The aggregate KKT error the **strict** gate judges against `tol`
    /// (gh #528): [`IpoptCalculatedQuantities::curr_nlp_error_above_primal_noise`],
    /// which is `nlp_err` with each constraint row's residual counted only
    /// where it rises above what that row's residual can represent in floating
    /// point.
    ///
    /// The primal term of the KKT error is the one term Ipopt leaves as a bare
    /// absolute residual (the other two carry `s_d` / `s_c`), and it is
    /// quantised in units of `eps ·` the rows' own magnitude. Once that quantum
    /// exceeds `tol` — constraint values past `~4.5e7` at the `1e-8` default —
    /// `nlp_err <= tol` stops being a statement about the iterate: it asks the
    /// residual to land on an exact `0` rather than on one ulp, which is
    /// arithmetic luck, and every iterate that misses keeps the solve running
    /// at a point it cannot improve until the step collapses
    /// (`Search_Direction_Becomes_Too_Small`, on LPs whose optimum POUNCE
    /// already had to 8 significant figures).
    ///
    /// Only this gate reads the floored value. `constr_viol` is still tested
    /// against `constr_viol_tol` on the full, unfloored residual, and the
    /// scale-relative veto still sees it too — so the noise floor can never
    /// admit a violation the user's own feasibility tolerance would reject, it
    /// only stops an unrepresentable one from vetoing a certificate. The
    /// acceptable-level band is deliberately left on the raw `nlp_err`: it sits
    /// two decades above `tol`, far clear of any realistic quantum.
    ///
    /// A non-finite `nlp_err` is passed through untouched — `f64::min` returns
    /// the *other* operand at `NaN`, which would launder exactly the
    /// `Invalid_Number_Detected` signal gh #292 built `curr_nlp_error`'s
    /// `has_valid_numbers` sweep to raise.
    ///
    /// On finite input the `min` is belt-and-braces rather than a live choice:
    /// `nlp_error(true)` shares its dual and complementarity terms with
    /// `nlp_error(false)` and `amax_above_floor` returns at most the vector's
    /// own `amax` on every path including its fallbacks, so
    /// `above_primal_noise <= nlp_err` always. It is kept so that the gate
    /// cannot be loosened by a future change to either accessor without that
    /// change being deliberate.
    /// Whether the gh #528 primal noise floor is live. `0` (or a negative
    /// value, which the option's lower bound already refuses) is the opt-out
    /// back to upstream Ipopt's bare-absolute primal term; the accessor is not
    /// even called then, so the opt-out costs nothing as well as changing
    /// nothing.
    fn noise_floor_enabled(&self) -> bool {
        self.primal_noise_floor_kappa > 0.0
    }

    fn strict_overall(nlp_err: Number, above_primal_noise: Number) -> Number {
        if !nlp_err.is_finite() {
            return nlp_err;
        }
        nlp_err.min(above_primal_noise)
    }

    /// Pure helper mirroring upstream
    /// `OptimalityErrorConvergenceCheck::CurrentIsAcceptable`. Tests
    /// the per-component `acceptable_*_tol` triplet plus the optional
    /// `acceptable_obj_change_tol` stability cross-check.
    fn passes_acceptable_tols(
        &self,
        overall: Number,
        dual_inf: Number,
        constr_viol: Number,
        compl_inf: Number,
        curr_f: Number,
    ) -> bool {
        // A point is never acceptable if the scaled error metric or the
        // objective itself is non-finite. Without the `curr_f` guard a NaN/Inf
        // objective with otherwise-small infeasibility (e.g. CUTE `himmelbj`,
        // where f evaluates to NaN at a near-feasible point) would be recorded
        // as the acceptable rollback point and reported under
        // `Solved_To_Acceptable_Level` with a `nan` objective.
        if !overall.is_finite() || !curr_f.is_finite() {
            return false;
        }
        let component_ok = overall <= self.acceptable_tol
            && dual_inf <= self.acceptable_dual_inf_tol
            && constr_viol <= self.acceptable_constr_viol_tol
            && compl_inf <= self.acceptable_compl_inf_tol;
        if !component_ok {
            return false;
        }
        // Upstream `IpOptErrorConvCheck.cpp:CurrentIsAcceptable` — when
        // an acceptable point has already been recorded and the user
        // tightened `acceptable_obj_change_tol` below the 1e20
        // sentinel, the iterate is only re-acceptable if `f` has moved
        // by less than `tol * max(1, |f|)` relative to the recorded
        // value. Skipped when no prior point exists or the cross-check
        // is disabled.
        if self.acceptable_obj_change_tol < 1e20 {
            if let Some(prev) = self.last_acceptable_obj {
                let denom = curr_f.abs().max(1.0);
                if (prev - curr_f).abs() >= self.acceptable_obj_change_tol * denom {
                    return false;
                }
            }
        }
        true
    }

    /// Advance the acceptable-level streak, returning whether the run should
    /// terminate with `ConvergedToAcceptable`.
    ///
    /// Acceptable-level termination is **count-based**: it needs
    /// `acceptable_iter` *consecutive* qualifying iterates. The masked-scale
    /// veto (gh #200) suppresses that termination, so the count has to keep
    /// running underneath the suppression — otherwise the mechanism cannot know
    /// where the unvetoed run would have stopped.
    ///
    /// The subtle part, and an earlier bug: `masked` is **not constant over a
    /// run**. `obj_scale` is fixed, but the veto's other condition is
    /// `unscaled_err > acceptable_tol`, and that quantity crosses the bar
    /// during the endgame — the crossing *is* the veto lifting. A streak can
    /// therefore straddle the boundary. Keeping two disjoint counters (a real
    /// one and a shadow), each reset by the other's phase, silently discarded a
    /// streak the unvetoed run would have kept: fourteen unmasked qualifying
    /// iterates followed by one masked qualifying iterate left the real count at
    /// zero, where the baseline would have reached fifteen and stopped. The run
    /// then fell through to `max_iter` — with no snapshot armed, because the
    /// shadow had only just started — and returned a bare failure where the
    /// baseline returned `Solved_To_Acceptable_Level`. That is precisely the
    /// "never worse" guarantee failing.
    ///
    /// So there is **one** counter, advanced on `acceptable_now` regardless of
    /// `masked`. `masked` decides only what happens when it crosses the
    /// threshold: terminate, or record that a termination was refused here —
    /// which is exactly the iterate the unvetoed run would have returned.
    ///
    /// The gh #533 progress test is the second thing that can refuse at the
    /// crossing, and it is undone by the same machinery — see
    /// [`Self::streak_has_flattened`]. Everything about the count is unchanged
    /// by it: the streak advances on the band test alone, so a progress refusal
    /// still records exactly the iterate the unvetoed run would have returned.
    fn note_acceptable(
        &mut self,
        acceptable_now: bool,
        masked: bool,
        nlp_err: Number,
        curr_f: Number,
    ) -> bool {
        if !acceptable_now {
            self.acceptable_count = 0;
            self.acceptable_window.clear();
            return false;
        }
        self.acceptable_count += 1;
        self.push_progress_sample(nlp_err, curr_f);
        if self.acceptable_count < self.acceptable_iter {
            return false;
        }
        if masked {
            self.acceptable_veto_fired = true;
            self.masked_acceptable_veto_fired = true;
            return false;
        }
        // gh #533: the streak says the error has been inside the band for
        // `acceptable_iter` iterations; it says nothing about whether the solve
        // has stopped moving. Refuse the termination while the window shows it
        // has not, and let the run continue — the refusal is recorded, so a run
        // that goes nowhere still ends at this point under this status.
        if !self.streak_has_flattened()
            && self.acceptable_progress_refusals < ACCEPTABLE_PROGRESS_MAX_REFUSALS
        {
            if !self.acceptable_veto_fired {
                tracing::info!(
                    nlp_err,
                    obj = curr_f,
                    acceptable_tol = self.acceptable_tol,
                    window = self.acceptable_window.len(),
                    kappa = self.acceptable_progress_kappa,
                    "refusing an acceptable-level termination: the error has been inside \
                     the acceptable band for the whole streak but is still moving across \
                     it, so the streak has not flattened; continuing \
                     (acceptable_progress_kappa=0 disables)"
                );
            }
            self.acceptable_progress_refusals += 1;
            self.acceptable_veto_fired = true;
            return false;
        }
        true
    }

    /// Length of the streak window the gh #533 progress test judges: the
    /// streak's own length, clamped to `1..=`[`ACCEPTABLE_PROGRESS_WINDOW_MAX`].
    ///
    /// A length of 1 is representable and means the test is inert:
    /// [`Self::streak_has_flattened`] declines to judge a window that short,
    /// because a single iterate carries no progress information. So
    /// `acceptable_iter = 1` never refuses, which is right — the user asked to
    /// stop at the first qualifying iterate.
    fn progress_window_len(&self) -> usize {
        (self.acceptable_iter.max(1) as usize).clamp(1, ACCEPTABLE_PROGRESS_WINDOW_MAX)
    }

    /// Record one qualifying iterate in the streak window, evicting the oldest
    /// sample once the window is full.
    fn push_progress_sample(&mut self, nlp_err: Number, curr_f: Number) {
        let cap = self.progress_window_len();
        self.acceptable_window.push_back((nlp_err, curr_f));
        while self.acceptable_window.len() > cap {
            self.acceptable_window.pop_front();
        }
    }

    /// Has the solve actually *flattened* over the iterates that made up the
    /// acceptable-level streak (gh #533)?
    ///
    /// The streak criterion on its own is a band test repeated
    /// `acceptable_iter` times: it asks whether the KKT error is small, never
    /// whether anything has stopped moving. Those come apart, and when they do
    /// the solve stops at a point that is near-stationary *for the current
    /// barrier subproblem* — a much weaker statement than near-KKT for the NLP —
    /// and returns a worse answer under a weaker status than continuing would
    /// have reached. Measured on two corpus models at `main @ 880b360b`:
    /// `kissing` (Vanderbei) stopped at iteration 103 with objective
    /// `1.00000108` and `Solved_To_Acceptable_Level`, where continuing reaches
    /// `0.84544259` and a strict certificate at 550 — 18% high, and Ipopt's own
    /// answer to eight figures is the lower one; `NARX_CFy` (Mittelmann)
    /// stopped at 565 with both residuals near `1e-7`, where 60 more iterations
    /// (25 s, inside the benchmark's 300 s limit) collapse them by five orders
    /// and beat both its own acceptable answer and Ipopt's.
    ///
    /// So: flat means *neither the error nor the objective moved* across the
    /// window, and the yardstick for both is a fraction
    /// `acceptable_progress_kappa` of `acceptable_tol` —
    ///
    /// - the error's absolute spread `max − min` against
    ///   `kappa · acceptable_tol`;
    /// - the objective's spread against `kappa · acceptable_tol · max(1, |f|)`,
    ///   the same relative form upstream's own `acceptable_obj_change_tol`
    ///   cross-check uses.
    ///
    /// **Spread, not trend, and either one alone is enough to refuse.** Both
    /// choices are load-bearing, and `kissing` is why:
    ///
    /// - Its `inf_du` over the last four iterates of the streak ran `3.35e-08 →
    ///   8.18e-08 → 1.08e-07 → 4.15e-07` — the error the solver stopped on was
    ///   an order of magnitude *worse* than one it had already achieved inside
    ///   the same streak. A trend test reads that as "not improving" and stops;
    ///   a spread test reads it as what it is, an iterate still wandering
    ///   across the band, and keeps going. The same holds in the other
    ///   direction: an error still descending through the band has not settled
    ///   either, and a solve that is still descending is one that may yet
    ///   certify.
    /// - Its objective was flat to all eight printed figures over those same
    ///   iterates (`1.0000011e+00` throughout) while the continued run moved it
    ///   by 15%. Requiring *both* signals to show movement before refusing
    ///   would therefore have stopped exactly where it stopped before.
    ///
    /// The band is the right yardstick because the question is scoped to it:
    /// the point is being certified as good to `acceptable_tol`, so "settled"
    /// has to mean settled on that scale. It also gets the user-intent
    /// monotonicity right in the one direction that matters — a *widened*
    /// `acceptable_tol` widens the flat bar with it, so a user who asked for an
    /// early exit at a loose band keeps getting one. Tightening
    /// `acceptable_tol` makes the test more eager to keep solving, which is the
    /// direction that cannot fabricate a verdict: a refusal is always undone at
    /// the end of a run that fails to do better (see
    /// `IpoptAlgorithm::honour_refused_certificate`), so its worst case is
    /// spent iterations, never a wrong answer.
    ///
    /// Returns `true` — flat, terminate — whenever the test cannot see enough
    /// to judge: `acceptable_progress_kappa <= 0` (the documented opt-out) or
    /// `NaN`, a window not yet full, a window of one, or any non-finite sample.
    /// Refusing on missing evidence would spend iterations for no stated reason.
    fn streak_has_flattened(&self) -> bool {
        if self.acceptable_progress_kappa.is_nan() || self.acceptable_progress_kappa <= 0.0 {
            return true;
        }
        // A partial window is not evidence of movement. (Unreachable from
        // `note_acceptable`, which only asks once the count has reached
        // `acceptable_iter` and pushes one sample per count, but the predicate
        // must not depend on that coincidence.)
        if self.acceptable_window.len() < self.progress_window_len()
            || self.acceptable_window.len() < 2
        {
            return true;
        }
        let bar = self.acceptable_progress_kappa * self.acceptable_tol;
        let (mut err_lo, mut err_hi) = (Number::INFINITY, Number::NEG_INFINITY);
        let (mut f_lo, mut f_hi) = (Number::INFINITY, Number::NEG_INFINITY);
        for &(err, f) in &self.acceptable_window {
            if !err.is_finite() || !f.is_finite() {
                return true;
            }
            err_lo = err_lo.min(err);
            err_hi = err_hi.max(err);
            f_lo = f_lo.min(f);
            f_hi = f_hi.max(f);
        }
        // `f` from the newest sample, matching `passes_acceptable_tols`'
        // `max(1, |f|)` denominator convention.
        let f_curr = self.acceptable_window.back().map_or(0.0, |&(_, f)| f);
        let err_flat = err_hi - err_lo <= bar;
        let obj_flat = f_hi - f_lo <= bar * f_curr.abs().max(1.0);
        err_flat && obj_flat
    }

    /// Fraction of a row's own magnitude a violation must exceed before the
    /// scale-relative machinery treats the row as genuinely violated —
    /// used both to veto a success certificate and as an alternative
    /// violation floor for rapid infeasibility detection.
    ///
    /// `max(100·constr_viol_tol, 1e-2)`: at the default `constr_viol_tol =
    /// 1e-4` this is 1% — a row eaten to 1% of everything it is made of is not
    /// a satisfied row at any scale. The `1e-2` floor is deliberate slack for
    /// the accepting direction: an interior-point run converges inequality
    /// residuals to *absolute* levels, so on a row of magnitude `1e-6` a
    /// converged residual near `1e-9` is a solved row at 0.1% relative — a
    /// tighter relative bar would veto genuine solutions on small-magnitude
    /// rows, the exact failure the clamped form in
    /// `pounce_common::tolerance::is_negligible` exists to avoid. The scale
    /// non-invariance this leaves (`x >= 0.7` at row scale `1e-12` is violated
    /// by 14%, well above any plausible bar; a knife-edge 0.9% violation is
    /// not) is the conservative direction: too-loose withholds a verdict,
    /// too-tight fabricates one.
    fn relative_viol_threshold(&self) -> Number {
        (100.0 * self.constr_viol_tol).max(MIN_INFEAS_VIOL_FLOOR)
    }

    /// Absolute violation floor for rapid infeasibility detection:
    /// `max(infeas_viol_kappa · constr_viol_tol, 1e-2)`.
    ///
    /// The same shape as [`Self::relative_viol_threshold`] and clamped for the
    /// same reason (gh #519): the product alone slides with the user's
    /// feasibility tolerance, so a *tighter* `constr_viol_tol` admitted smaller
    /// and smaller violations as evidence of infeasibility — the one direction
    /// a feasibility tolerance must never move this predicate. See
    /// [`MIN_INFEAS_VIOL_FLOOR`]. Raising `infeas_viol_kappa` still raises the
    /// floor; the clamp only stops it from falling below what the defaults
    /// consider an acceptable violation.
    fn absolute_viol_threshold(&self) -> Number {
        (self.infeas_viol_kappa * self.constr_viol_tol).max(MIN_INFEAS_VIOL_FLOOR)
    }

    /// Pure predicate for a single infeasible-stationary iterate: the
    /// constraint violation is bounded away from zero — absolutely
    /// (`constr_viol` above [`Self::absolute_viol_threshold`]) **or relative to
    /// the violated row's own magnitude** (`rel_viol` above
    /// [`Self::relative_viol_threshold`]; a row violated by 10% of everything
    /// it is made of is bounded away from feasible no matter how small its
    /// numbers are) — and the scaled infeasibility gradient `‖Jᵀc‖/max(1,‖c‖)`
    /// is at or below `infeas_stationarity_tol`. Returns `false` when rapid
    /// infeasibility detection is disabled (either knob non-positive).
    ///
    /// The relative arm changes only this pre-filter; the verdict still
    /// requires the direct no-descent confirmation in
    /// `check_convergence_with_state`, which is what protects against the
    /// false-infeasibility failures the surrogate alone was measured to
    /// produce.
    fn is_infeasible_stationary(
        &self,
        constr_viol: Number,
        rel_viol: Number,
        stationarity: Number,
    ) -> bool {
        if self.infeas_stationarity_tol <= 0.0 || self.infeas_max_streak <= 0 {
            return false;
        }
        (constr_viol > self.absolute_viol_threshold() || rel_viol > self.relative_viol_threshold())
            && stationarity <= self.infeas_stationarity_tol
    }

    /// Advance the rapid-infeasibility-detection streak by one
    /// iteration. An infeasible-stationary iterate (see
    /// [`Self::is_infeasible_stationary`]) increments the streak; any
    /// other iterate resets it to zero. Returns `true` once the streak
    /// reaches `infeas_max_streak`, signalling the caller to terminate
    /// with `ConvergenceStatus::LocallyInfeasible`. The streak guards
    /// against firing on a transient flat spot.
    ///
    /// The **relative** arm additionally requires the relative violation to
    /// have stopped improving — "bounded away from feasible" must mean *not
    /// still converging*. The no-descent confirmation cannot provide that
    /// guard here: it compares violations absolutely, so in the small-scale
    /// regime the relative arm targets (violation ~1e-9 and falling), no
    /// "materially less-violating" point registers and the confirmation is
    /// vacuous. Measured on QSCORPIO: the detector fired at iteration 57 with
    /// the endgame still cutting the violation 16× over its last five
    /// iterations (4.6e-9 → 2.9e-10 relative 4.6e-2 → 2.9e-3); five more
    /// iterations reached `Optimal Solution Found`. An iterate that improved
    /// the relative violation by more than 10% since the previous check
    /// therefore resets the streak; a genuinely infeasible row's violation is
    /// pinned at its infeasibility gap and cannot improve at all.
    fn note_infeasible_stationary(
        &mut self,
        constr_viol: Number,
        rel_viol: Number,
        stationarity: Number,
    ) -> bool {
        let still_improving = rel_viol < 0.9 * self.prev_rel_viol;
        self.prev_rel_viol = rel_viol;
        // Only the relative arm is progress-gated; the absolute arm keeps its
        // own guard (the direct no-descent confirmation, which is meaningful
        // at absolute violation scales).
        let effective_rel = if still_improving { 0.0 } else { rel_viol };
        if self.is_infeasible_stationary(constr_viol, effective_rel, stationarity) {
            self.infeas_streak += 1;
            self.infeas_streak >= self.infeas_max_streak
        } else {
            self.infeas_streak = 0;
            false
        }
    }
}

impl ConvCheck for OptErrorConvCheck {
    fn certificate_vetoed(&self) -> bool {
        self.veto_fired
    }

    fn acceptable_certificate_vetoed(&self) -> bool {
        self.acceptable_veto_fired
    }

    fn check_convergence(&mut self, nlp_err: Number, iter_count: Index) -> ConvergenceStatus {
        if nlp_err <= self.tol {
            return ConvergenceStatus::Converged;
        }
        // `acceptable_iter == 0` disables acceptable-level termination,
        // mirroring upstream `IpOptErrorConvCheck.cpp:241`
        // (`if( acceptable_iter_ > 0 && CurrentIsAcceptable() )`). Without
        // the `> 0` guard, a zero would make `acceptable_count >= 0` fire on
        // the first acceptable iterate — the opposite of "disabled".
        //
        // The gh #533 progress test deliberately does NOT live here. It needs
        // the objective, which this entry point does not receive, and its two
        // callers do not want it: unit tests exercising the scalar state
        // machine, and `RestoConvCheckAdapter`, whose inner acceptable-level
        // answer feeds the "may the trial point leave restoration" decision
        // rather than a user-facing verdict — and which has no refused-
        // certificate fallback of its own to undo a refusal with.
        if self.acceptable_iter > 0 && nlp_err <= self.acceptable_tol {
            self.acceptable_count += 1;
            if self.acceptable_count >= self.acceptable_iter {
                return ConvergenceStatus::ConvergedToAcceptable;
            }
        } else {
            self.acceptable_count = 0;
        }
        if iter_count >= self.max_iter {
            return ConvergenceStatus::MaxIterExceeded;
        }
        ConvergenceStatus::Continue
    }

    fn check_convergence_with_state(
        &mut self,
        nlp_err: Number,
        iter_count: Index,
        data: &IpoptDataHandle,
        cq: &IpoptCqHandle,
    ) -> ConvergenceStatus {
        // Mirror upstream `IpOptErrorConvCheck.cpp::CheckConvergence`:
        // the scaled scalar `nlp_err` must drop below `tol` AND each
        // per-component value must sit under its own tolerance. The
        // component tolerances (`dual_inf_tol`/`constr_viol_tol`/
        // `compl_inf_tol`) are defined on the *unscaled* (user-original)
        // residuals — both upstream and per pounce's own option help text
        // — so we gate on the unscaled accessors. This resolves the former
        // M1 deviation (gating on internally-scaled residuals), which let
        // an ill-conditioned, nlp_scaling-deflated solve report
        // `Solve_Succeeded` while the user-space duals had drifted
        // (pounce#173). When no scaling is active the unscaled accessors
        // return the scaled values unchanged, so behaviour is identical on
        // the common path.
        let cq_ref = cq.borrow();
        let dual_inf = cq_ref.curr_unscaled_dual_infeasibility_max();
        let constr_viol = cq_ref.curr_unscaled_primal_infeasibility_max();
        let compl_inf = cq_ref.curr_unscaled_complementarity_max();
        let rel_viol = cq_ref.curr_relative_primal_infeasibility_max();
        let curr_f = cq_ref.curr_f();
        let unscaled_err = cq_ref.curr_unscaled_nlp_error();
        // gh #528 — see `strict_overall`. Only the strict gate below reads
        // this; `nlp_err` itself carries on to the acceptable-level band, the
        // rapid-infeasibility pre-filter and everything downstream unchanged.
        //
        // Computed only on the iterations where it can change the verdict.
        // That laziness is doing real work, because the accessor is not two
        // extra Jacobian sweeps on top of a cached number — it is
        // `nlp_error(true)`, a second evaluation of the *whole* KKT error:
        // `optimality_error_scaling`, `curr_grad_lag_x`/`_s` (each a fresh
        // allocation plus two mat-vecs, and uncached — `nlp_error` has no
        // entry among the caches in `ipopt_cq.rs`), all four complementarity
        // vectors and the `has_valid_numbers` sweep, plus the two
        // `compute_row_amax` sweeps the floors need. Anyone reusing this
        // accessor anywhere hotter should read that cost first.
        //
        // The laziness is exact, not an approximation: below `tol` the floored
        // value is smaller still and the gate passes either way, and with any
        // component tolerance already blown `passes_component_tols` is false
        // whatever the aggregate says.
        //
        // gh #532 — the scale-relative floor under `dual_inf_tol`. Computed on
        // the same terms `dual_inf` was assembled from, and only where it can
        // change the verdict: below `dual_inf_tol` the absolute arm has already
        // passed and the floor can only be looser, and with the primal or
        // complementarity component already blown no floor on the dual makes a
        // certificate. That laziness matters because the accessor repeats
        // `curr_grad_lag_x`'s `∇f` and two transpose products.
        let primal_compl_pass =
            constr_viol <= self.constr_viol_tol && compl_inf <= self.compl_inf_tol;
        let dual_scale =
            if primal_compl_pass && dual_inf > self.dual_inf_tol && self.dual_inf_scale_kappa > 0.0
            {
                cq_ref.curr_unscaled_dual_infeasibility_scale_max()
            } else {
                0.0
            };
        let components_pass = primal_compl_pass && dual_inf <= self.dual_inf_bound(dual_scale);
        let strict_err = if nlp_err <= self.tol || !components_pass || !self.noise_floor_enabled() {
            nlp_err
        } else {
            Self::strict_overall(
                nlp_err,
                cq_ref.curr_nlp_error_above_primal_noise(self.primal_noise_floor_kappa),
            )
        };
        // The gate asks whether *our* scaling clamped, not how the user chose
        // to scale their objective — see `certificate_masked`.
        let obj_scale = cq_ref.computed_obj_scaling_factor();
        drop(cq_ref);

        // Scale-relative feasibility veto (#385 Step 6; extended to equality
        // rows by #390, which plumbs the pre-fold RHS back so `|c_i|` has a
        // declared magnitude to be relative to). The absolute
        // `constr_viol_tol` gate cannot tell "satisfied" from "violated by 14%
        // of everything the row is" once the row's numbers are small: `x >= 0.7`
        // written as `1e-12·x >= 0.7e-12` has an absolute violation of `1e-13`
        // at `x = 0.6` — under every absolute tolerance, while the same empty
        // feasible set written at unit scale is reported infeasible. Refuse a
        // certificate whose point still has a constraint row violated by more
        // than `relative_viol_threshold` of its own magnitude, and let the run
        // continue: for a genuinely infeasible model the rapid-infeasibility
        // detection below then reaches the honest verdict (its violation floor
        // understands the same relative measure), and for anything else the
        // budget bounds the cost — after `VETO_MAX_EXTRA_ITERS` blocked
        // iterations the veto disengages and the run terminates exactly as it
        // would have, so no verdict is ever lost to it.
        let rel_veto = rel_viol > self.relative_viol_threshold()
            && self.rel_infeas_extra_iters < VETO_MAX_EXTRA_ITERS;
        let mut rel_veto_blocked = false;

        // gh #200: refuse a certificate the objective scaling has masked, and
        // keep iterating. A constant objective scale cancels out of the Newton
        // step and every line-search test is scale-invariant, so the continued
        // run follows exactly the trajectory an unscaled run would and reaches
        // the true minimum — at which point the unscaled error falls under
        // `acceptable_tol`, the veto lifts, and an honest strict certificate is
        // issued. Refusing to stop early is the whole intervention; the strict
        // tolerance in scaled space is untouched.
        // Only the masked arms spend the masked veto's budget — see
        // `masked_acceptable_veto_fired`.
        if self.veto_fired || self.masked_acceptable_veto_fired {
            self.veto_extra_iters += 1;
        }
        // Call the bet off once it has plainly not paid off, so a veto that can
        // never lift cannot cost an unbounded number of iterations. The refused
        // certificate is restored regardless, so this bounds cost, not
        // correctness.
        let budget_spent = self.veto_extra_iters > VETO_MAX_EXTRA_ITERS;
        // A non-finite objective disqualifies the veto outright. `passes_component_tols`
        // never inspects `f`, so a strict certificate can pass at an iterate whose
        // objective is NaN while its residuals are finite and tiny — and the unvetoed
        // run returns exactly that, NaN objective and all. Refusing it would arm a
        // snapshot the restore then declines (`honour_refused_certificate` requires a
        // finite objective), surfacing a failure where the baseline reported success.
        // Declining to engage keeps that case bit-identical to the baseline instead.
        // The acceptable-level side already had this property: finite `f` is a
        // precondition of qualifying there.
        let masked = curr_f.is_finite()
            && !budget_spent
            && certificate_masked(
                obj_scale,
                unscaled_err,
                self.obj_scale_certificate_threshold,
                self.acceptable_tol,
            );
        // Record a refusal only when a strict certificate was genuinely on the
        // table. `masked` alone is far broader — it holds on ordinary iterates
        // long before convergence — and using it would arm the fallback (and
        // snapshot an arbitrary mid-solve iterate) on runs that were never
        // about to stop.
        let refusing_strict = masked
            && self.passes_component_tols(strict_err, dual_inf, constr_viol, compl_inf, dual_scale);
        if refusing_strict && !self.veto_fired {
            self.veto_fired = true;
            tracing::info!(
                obj_scale,
                unscaled_kkt_error = unscaled_err,
                scaled_nlp_error = nlp_err,
                threshold = self.obj_scale_certificate_threshold,
                "refusing a termination certificate masked by an extreme objective scale; \
                 continuing toward the true minimum (obj_scale_certificate_threshold=0 disables)"
            );
        }

        if !masked
            && self.passes_component_tols(strict_err, dual_inf, constr_viol, compl_inf, dual_scale)
        {
            if rel_veto {
                rel_veto_blocked = true;
                if self.rel_infeas_extra_iters == 0 {
                    tracing::info!(
                        rel_viol,
                        constr_viol,
                        threshold = self.relative_viol_threshold(),
                        "refusing a success certificate: a constraint row is still \
                         violated by more than the scale-relative threshold of its own \
                         magnitude; continuing (bounded by the veto budget)"
                    );
                }
            } else {
                // The certificate is going out with a dual infeasibility above
                // `dual_inf_tol`, which the end-of-run summary will print
                // beside `EXIT: Optimal Solution Found`. Say why, once.
                if dual_inf > self.dual_inf_tol && !self.dual_floor_reported {
                    self.dual_floor_reported = true;
                    tracing::info!(
                        dual_inf,
                        dual_scale,
                        dual_inf_tol = self.dual_inf_tol,
                        bound = self.dual_inf_bound(dual_scale),
                        "certifying with a dual infeasibility above dual_inf_tol: it is \
                         within the scale-relative floor set by the terms the Lagrangian \
                         gradient is built from (dual_inf_scale_kappa=0 disables)"
                    );
                }
                return ConvergenceStatus::Converged;
            }
        }
        // `acceptable_iter == 0` disables acceptable-level termination
        // (upstream `IpOptErrorConvCheck.cpp:241`). See `check_convergence`.
        // The veto covers this branch too, so a refused strict certificate is
        // not merely swapped for an acceptable-level one at the same wrong
        // point. Acceptable-point *storage* is deliberately left un-vetoed —
        // that stashed point is the rollback target if the run later stalls.
        let mut acceptable_now = self.acceptable_iter > 0
            && self.passes_acceptable_tols(nlp_err, dual_inf, constr_viol, compl_inf, curr_f);
        // The scale-relative veto covers the acceptable band for the same
        // reason the masked-scale veto does: a refused strict certificate must
        // not be swapped for an acceptable-level one at the same wrong point.
        if acceptable_now && rel_veto {
            acceptable_now = false;
            rel_veto_blocked = true;
        }
        if rel_veto_blocked {
            self.rel_infeas_extra_iters += 1;
        }
        if self.note_acceptable(acceptable_now, masked, nlp_err, curr_f) {
            return ConvergenceStatus::ConvergedToAcceptable;
        }
        if iter_count >= self.max_iter {
            return ConvergenceStatus::MaxIterExceeded;
        }
        // Rapid infeasibility detection — recognise an iterate
        // converging to a stationary point of the constraint
        // violation with the violation bounded away from zero, and
        // exit with `LocallyInfeasible` instead of grinding to
        // `max_iter` or thrashing restoration. Gated behind an
        // `infeas_max_streak`-iteration streak to avoid firing on a
        // transient flat spot. The outer guard skips the two
        // transpose-products when detection is disabled.
        if self.infeas_stationarity_tol > 0.0 && self.infeas_max_streak > 0 {
            // The surrogate here is a cheap PRE-FILTER, not the verdict. It is
            // a threshold on `||J^T c|| / max(1, ||c||)`, which is not
            // scale-invariant: under a row scaling `dc` the numerator carries
            // `dc^2` while the denominator clamps at 1, so an aggressive scaling
            // drives it to zero regardless of where the iterate is. That is how
            // HS13 from x0 = (1e4, 1e4) reached `5e-14` at a point whose
            // constraint violation was 0.51, and got reported infeasible.
            //
            // Retuning does not fix it. Measured over 800 corpus models, every
            // tolerance that fires on genuinely infeasible problems also
            // introduces new false infeasibility (>= 3 models at the smallest
            // viable value), and measuring the surrogate unscaled or
            // scale-invariantly does not separate the cases either. So the
            // surrogate stays as-is, and the claim the status actually makes --
            // that no local move reduces the violation -- is confirmed directly
            // before the verdict is issued.
            let stationarity = cq.borrow().curr_infeasibility_stationarity();
            if self.note_infeasible_stationary(constr_viol, rel_viol, stationarity) {
                if cq.borrow().infeasibility_descent_available() {
                    // Descent exists: not a stationary point of the violation,
                    // so the surrogate was wrong here. Drop the streak and keep
                    // solving.
                    self.infeas_streak = 0;
                } else {
                    return ConvergenceStatus::LocallyInfeasible;
                }
            }
        }
        // Time-budget gates. When the application installed a shared
        // [`Deadline`] (pounce#242) it is authoritative: it measures
        // global elapsed time from a fixed start instant, so it fires
        // correctly even inside the restoration inner IPM, whose fresh
        // `timing.overall_alg` is never started. Absent a deadline (the
        // direct-driver / unit-test path), fall back to the `overall_alg`
        // timer, which `IpoptApplication` starts at the top of
        // `optimize_constrained`; `live_*` returns the running elapsed
        // without forcing a `start/end` cycle. Upstream
        // `IpOptErrorConvCheck.cpp::CheckConvergence` reads the
        // application-level start time similarly.
        let d = data.borrow();
        if let Some(deadline) = d.deadline.as_ref() {
            match deadline.exceeded() {
                Some(pounce_common::timing::DeadlineKind::Cpu) => {
                    return ConvergenceStatus::CpuTimeExceeded;
                }
                Some(pounce_common::timing::DeadlineKind::Wall) => {
                    return ConvergenceStatus::WallTimeExceeded;
                }
                None => {}
            }
        } else {
            let timing = &d.timing;
            if timing.overall_alg.live_cpu_time() >= self.max_cpu_time {
                return ConvergenceStatus::CpuTimeExceeded;
            }
            if timing.overall_alg.live_wallclock_time() >= self.max_wall_time {
                return ConvergenceStatus::WallTimeExceeded;
            }
        }
        ConvergenceStatus::Continue
    }

    fn current_passes_strict(
        &self,
        nlp_err: Number,
        _data: &IpoptDataHandle,
        cq: &IpoptCqHandle,
    ) -> bool {
        // The strict per-component gate of `check_convergence_with_state`, minus
        // the masking veto — see the trait doc. Unscaled per-component residuals,
        // matching that method (the `*_tol` triplet is defined on the
        // user-original residuals).
        let cq_ref = cq.borrow();
        let dual_inf = cq_ref.curr_unscaled_dual_infeasibility_max();
        let constr_viol = cq_ref.curr_unscaled_primal_infeasibility_max();
        let compl_inf = cq_ref.curr_unscaled_complementarity_max();
        // Same noise-floored aggregate the strict gate uses (gh #528) — this
        // predicate exists to answer "would that gate have passed here?", so it
        // has to ask the same question. Same scale-relative dual floor
        // (gh #532), and lazily for the same reason.
        let strict_err = if self.noise_floor_enabled() {
            Self::strict_overall(
                nlp_err,
                cq_ref.curr_nlp_error_above_primal_noise(self.primal_noise_floor_kappa),
            )
        } else {
            nlp_err
        };
        let dual_scale = if dual_inf > self.dual_inf_tol && self.dual_inf_scale_kappa > 0.0 {
            cq_ref.curr_unscaled_dual_infeasibility_scale_max()
        } else {
            0.0
        };
        drop(cq_ref);
        self.passes_component_tols(strict_err, dual_inf, constr_viol, compl_inf, dual_scale)
    }

    fn tol_or_default(&self) -> Number {
        self.tol
    }

    fn constr_viol_tol_or_default(&self) -> Number {
        self.constr_viol_tol
    }

    fn acceptable_constr_viol_tol_or_default(&self) -> Number {
        self.acceptable_constr_viol_tol
    }

    fn set_tolerance(&mut self, name: &str, value: Number) -> bool {
        match name {
            "tol" => self.tol = value,
            "dual_inf_tol" => self.dual_inf_tol = value,
            "constr_viol_tol" => self.constr_viol_tol = value,
            "compl_inf_tol" => self.compl_inf_tol = value,
            "acceptable_tol" => self.acceptable_tol = value,
            "acceptable_dual_inf_tol" => self.acceptable_dual_inf_tol = value,
            "acceptable_constr_viol_tol" => self.acceptable_constr_viol_tol = value,
            "acceptable_compl_inf_tol" => self.acceptable_compl_inf_tol = value,
            "acceptable_obj_change_tol" => self.acceptable_obj_change_tol = value,
            _ => return false,
        }
        true
    }

    fn current_is_acceptable(&self, nlp_err: Number) -> bool {
        // Scalar fallback used when the caller has no `IpoptCq` handle
        // (e.g. unit tests). The state-aware variant
        // [`Self::current_is_acceptable_with_state`] mirrors upstream
        // more faithfully by gating on the per-component
        // `acceptable_*_tol` triplet plus the obj-change cross-check.
        nlp_err.is_finite() && nlp_err <= self.acceptable_tol
    }

    fn current_is_acceptable_with_state(
        &self,
        nlp_err: Number,
        _data: &IpoptDataHandle,
        cq: &IpoptCqHandle,
    ) -> bool {
        let cq_ref = cq.borrow();
        // Unscaled per-component residuals — see `check_convergence_with_state`
        // (the `acceptable_*_tol` triplet is likewise defined on the
        // user-original residuals).
        let dual_inf = cq_ref.curr_unscaled_dual_infeasibility_max();
        let constr_viol = cq_ref.curr_unscaled_primal_infeasibility_max();
        let compl_inf = cq_ref.curr_unscaled_complementarity_max();
        let rel_viol = cq_ref.curr_relative_primal_infeasibility_max();
        let curr_f = cq_ref.curr_f();
        drop(cq_ref);
        // The scale-relative veto reaches acceptable-point *storage* too,
        // unlike the masked-scale (#200) veto above it. That veto refuses a
        // possibly-premature stop at a point that is still genuinely feasible,
        // so the stash stays a legitimate rollback target. Here the point has
        // a constraint row violated by more than the relative threshold of
        // its own magnitude — it is not acceptable in any honest sense, and a
        // stall later in the run must not roll back to it and surface
        // `Solved_To_Acceptable_Level` on an infeasible model (measured: an
        // infeasible row at scale `1e-10`, 100% violated, exited exactly that
        // way through this stash). Budget-aware like the certificate veto, so
        // a spent budget restores the old behaviour entirely.
        if rel_viol > self.relative_viol_threshold()
            && self.rel_infeas_extra_iters < VETO_MAX_EXTRA_ITERS
        {
            return false;
        }
        self.passes_acceptable_tols(nlp_err, dual_inf, constr_viol, compl_inf, curr_f)
    }

    fn set_curr_acceptable_obj(&mut self, obj: Number) {
        self.last_acceptable_obj = Some(obj);
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn converges_at_tol() {
        let mut c = OptErrorConvCheck::new();
        assert_eq!(c.check_convergence(1e-9, 0), ConvergenceStatus::Converged);
    }

    /// The scale-relative arm of rapid infeasibility detection (#385 Step 6):
    /// a row violated by a large fraction of its own magnitude is bounded away
    /// from feasible no matter how small its numbers are, so the pre-filter
    /// must fire even when the absolute violation is far below
    /// `infeas_viol_kappa * constr_viol_tol`.
    #[test]
    fn relative_violation_arms_the_infeasibility_prefilter() {
        let c = OptErrorConvCheck::new();
        // `x >= 0.7` at row scale 1e-12: absolute violation 1e-13 (invisible
        // to the absolute arm, floor is 1e-2), relative violation 0.14.
        assert!(c.is_infeasible_stationary(1e-13, 0.14, 1e-9));
        // The same iterate without the relative signal must NOT fire — this
        // is exactly the old behaviour.
        assert!(!c.is_infeasible_stationary(1e-13, 0.0, 1e-9));
        // A converged small-magnitude row (residual 1e-9 on a 1e-6-bound row,
        // 0.1% relative) stays under the 1% threshold.
        assert!(!c.is_infeasible_stationary(1e-9, 1e-3, 1e-9));
    }

    /// The relative arm's streak resets while the relative violation is still
    /// improving — "bounded away from feasible" must mean *not still
    /// converging*. QSCORPIO's endgame was cutting its violation 16× over
    /// five iterations when the un-guarded arm declared it locally
    /// infeasible; five more iterations reached the optimum.
    #[test]
    fn improving_relative_violation_resets_the_streak() {
        let mut c = OptErrorConvCheck::new();
        c.infeas_max_streak = 3;
        // A pinned relative violation (an infeasibility gap) accumulates.
        assert!(!c.note_infeasible_stationary(1e-13, 0.14, 1e-9));
        assert!(!c.note_infeasible_stationary(1e-13, 0.14, 1e-9));
        assert!(c.note_infeasible_stationary(1e-13, 0.14, 1e-9));
        // A geometrically shrinking one (a converging endgame) never fires.
        let mut c = OptErrorConvCheck::new();
        c.infeas_max_streak = 3;
        let mut rel = 0.5;
        for _ in 0..20 {
            assert!(
                !c.note_infeasible_stationary(1e-13, rel, 1e-9),
                "a converging endgame must not be declared infeasible"
            );
            rel *= 0.5;
        }
    }

    /// The relative-violation veto blocks a strict certificate the absolute
    /// tolerances would grant, and its budget bounds the cost: once spent,
    /// the certificate goes through exactly as before.
    #[test]
    fn relative_viol_threshold_is_floored() {
        let mut c = OptErrorConvCheck::new();
        // Default constr_viol_tol = 1e-4 -> threshold 1e-2.
        assert_eq!(c.relative_viol_threshold(), 1e-2);
        // A loosened constr_viol_tol loosens the relative bar with it.
        c.constr_viol_tol = 1e-3;
        assert_eq!(c.relative_viol_threshold(), 1e-1);
        // A tightened one must not push the relative bar below 1% — an
        // interior-point run converges inequality residuals to absolute
        // levels, and a tighter relative bar vetoes genuine solutions on
        // small-magnitude rows.
        c.constr_viol_tol = 1e-8;
        assert_eq!(c.relative_viol_threshold(), 1e-2);
    }

    #[test]
    fn acceptable_iter_count_threshold() {
        let mut c = OptErrorConvCheck {
            acceptable_iter: 3,
            ..Default::default()
        };
        // nlp_err between tol (1e-8) and acceptable (1e-6).
        assert_eq!(c.check_convergence(1e-7, 0), ConvergenceStatus::Continue);
        assert_eq!(c.check_convergence(1e-7, 1), ConvergenceStatus::Continue);
        assert_eq!(
            c.check_convergence(1e-7, 2),
            ConvergenceStatus::ConvergedToAcceptable
        );
    }

    #[test]
    fn acceptable_iter_zero_disables_acceptable_termination() {
        // Upstream `IpOptErrorConvCheck.cpp:241` gates the acceptable
        // counter on `acceptable_iter_ > 0`, so a zero disables the
        // acceptable-level exit entirely. Before the guard, `>= 0` made
        // pounce fire on the FIRST acceptable iterate (the opposite).
        let mut c = OptErrorConvCheck {
            acceptable_iter: 0,
            ..Default::default()
        };
        // Many iterates parked between tol (1e-8) and acceptable (1e-6)
        // must never trigger ConvergedToAcceptable; the run continues
        // until tol or max_iter.
        for k in 0..50 {
            assert_eq!(
                c.check_convergence(1e-7, k),
                ConvergenceStatus::Continue,
                "acceptable_iter=0 must not stop at the acceptable level (iter {k})"
            );
        }
        // tol is still honored regardless.
        assert_eq!(c.check_convergence(1e-9, 51), ConvergenceStatus::Converged);
    }

    #[test]
    fn streak_resets_when_above_acceptable() {
        let mut c = OptErrorConvCheck {
            acceptable_iter: 3,
            ..Default::default()
        };
        assert_eq!(c.check_convergence(1e-7, 0), ConvergenceStatus::Continue);
        // Above acceptable resets the counter.
        assert_eq!(c.check_convergence(1e-3, 1), ConvergenceStatus::Continue);
        assert_eq!(c.check_convergence(1e-7, 2), ConvergenceStatus::Continue);
        assert_eq!(c.check_convergence(1e-7, 3), ConvergenceStatus::Continue);
        assert_eq!(
            c.check_convergence(1e-7, 4),
            ConvergenceStatus::ConvergedToAcceptable
        );
    }

    #[test]
    fn passes_acceptable_tols_gates_on_per_component_triplet() {
        let c = OptErrorConvCheck {
            acceptable_tol: 1e-6,
            acceptable_dual_inf_tol: 1e-3,
            acceptable_constr_viol_tol: 1e-3,
            acceptable_compl_inf_tol: 1e-3,
            ..Default::default()
        };
        assert!(c.passes_acceptable_tols(1e-7, 1e-4, 1e-4, 1e-4, 0.0));
        // dual_inf above its acceptable threshold blocks.
        assert!(!c.passes_acceptable_tols(1e-7, 1.0, 1e-4, 1e-4, 0.0));
        // overall above acceptable_tol blocks.
        assert!(!c.passes_acceptable_tols(1e-5, 1e-4, 1e-4, 1e-4, 0.0));
    }

    #[test]
    fn passes_acceptable_tols_honors_obj_change_tol() {
        let mut c = OptErrorConvCheck {
            acceptable_tol: 1e-6,
            acceptable_dual_inf_tol: 1.0,
            acceptable_constr_viol_tol: 1.0,
            acceptable_compl_inf_tol: 1.0,
            acceptable_obj_change_tol: 0.1,
            ..Default::default()
        };
        // First call always acceptable (no prior obj).
        assert!(c.passes_acceptable_tols(1e-7, 0.0, 0.0, 0.0, 10.0));
        c.set_curr_acceptable_obj(10.0);
        // Same f → change well under threshold → still acceptable.
        assert!(c.passes_acceptable_tols(1e-7, 0.0, 0.0, 0.0, 10.0));
        // f moved by 2.0 with threshold 0.1 * max(1, |11.0|) = 1.1 →
        // absolute change 1.0 < 1.1: acceptable.
        assert!(c.passes_acceptable_tols(1e-7, 0.0, 0.0, 0.0, 11.0));
        // f moved by 5.0 — absolute change 5.0 > 1.5 = 0.1 * 15 →
        // rejected (the stability cross-check fires).
        assert!(!c.passes_acceptable_tols(1e-7, 0.0, 0.0, 0.0, 15.0));
    }

    use crate::conv_check::r#trait::ConvCheck;

    #[test]
    fn set_curr_acceptable_obj_records_for_cross_check() {
        let mut c = OptErrorConvCheck::new();
        assert!(c.last_acceptable_obj.is_none());
        ConvCheck::set_curr_acceptable_obj(&mut c, 4.2);
        assert_eq!(c.last_acceptable_obj, Some(4.2));
    }

    #[test]
    fn a_non_finite_objective_disqualifies_the_veto() {
        // `passes_component_tols` never inspects `f`, so a strict certificate can
        // pass at an iterate whose objective is NaN while its residuals are finite
        // and tiny — and the unvetoed run returns exactly that. Refusing it would
        // arm a snapshot that the restore then declines (it requires a finite
        // objective), surfacing a failure where the baseline reported success:
        // a never-worse violation, on the one path where the objective is not
        // usable as a tiebreak.
        let c = OptErrorConvCheck {
            tol: 1e-8,
            dual_inf_tol: 1.0,
            constr_viol_tol: 1e-4,
            compl_inf_tol: 1e-4,
            ..Default::default()
        };
        // The residuals alone say "converged"; the objective says nothing usable.
        assert!(c.passes_component_tols(1e-12, 1e-9, 0.0, 0.0, 0.0));
        // The masked predicate itself is unchanged — the finiteness gate lives at
        // the call site, where `curr_f` is in hand.
        assert!(certificate_masked(
            1e-8,
            8.4e-1,
            c.obj_scale_certificate_threshold,
            c.acceptable_tol
        ));
        // Both the guard's inputs behave as the call site composes them.
        for bad in [Number::NAN, Number::INFINITY, Number::NEG_INFINITY] {
            assert!(!bad.is_finite(), "{bad} should disqualify the veto");
        }
        assert!((1.0_f64).is_finite());
    }

    #[test]
    fn acceptable_streak_survives_a_masked_boundary_mid_streak() {
        // gh #200. `masked` is not constant over a run: it also depends on the
        // unscaled error crossing `acceptable_tol`, and that crossing is exactly
        // what happens during the endgame. So an acceptable-level streak can
        // straddle the boundary.
        //
        // The earlier implementation kept two disjoint counters, each reset by
        // the other's phase. Fourteen unmasked qualifying iterates followed by
        // one masked qualifying iterate left the real count at 0 while the
        // unvetoed run would have reached 15 and stopped — so the run fell
        // through to `max_iter` and returned a bare failure where the baseline
        // returned `Solved_To_Acceptable_Level`, with no snapshot armed to roll
        // back to. Never-worse, violated.
        //
        // Every iterate here is a *settled* one — same error, same objective —
        // so the gh #533 progress test is flat throughout and this test sees
        // only the masked-veto behaviour it is about. The progress test's own
        // arm is exercised in `a_wandering_streak_refuses_acceptable_termination`.
        const ERR: Number = 1e-7;
        const OBJ: Number = 1.0;
        let mut c = OptErrorConvCheck {
            acceptable_iter: 15,
            ..Default::default()
        };
        // 14 qualifying iterates while unmasked: no termination yet.
        for i in 0..14 {
            assert!(
                !c.note_acceptable(true, false, ERR, OBJ),
                "terminated early at {i}"
            );
        }
        // The 15th qualifies too, but the veto is now engaged. The streak must
        // be honoured — recorded as a refused termination, not discarded.
        assert!(
            !c.note_acceptable(true, true, ERR, OBJ),
            "a masked iterate must not terminate the run"
        );
        assert!(
            c.acceptable_veto_fired,
            "the streak crossed `acceptable_iter` while masked, so a termination was \
             refused here and must be recorded — otherwise the fallback has nothing to \
             restore and the run returns a bare failure"
        );
        assert!(
            c.masked_acceptable_veto_fired,
            "a masked refusal must be attributed to the masked arm — it is what spends \
             the masked veto's iteration budget"
        );

        // The mirror direction: a streak that begins masked and finishes
        // unmasked must terminate on the same iterate the baseline would.
        let mut c = OptErrorConvCheck {
            acceptable_iter: 15,
            ..Default::default()
        };
        for _ in 0..14 {
            assert!(!c.note_acceptable(true, true, ERR, OBJ));
        }
        assert!(
            c.note_acceptable(true, false, ERR, OBJ),
            "the veto lifted with the streak already at 14; the 15th qualifying iterate \
             must terminate exactly as it would without the mechanism"
        );

        // And a non-qualifying iterate still breaks the streak, in either phase.
        let mut c = OptErrorConvCheck {
            acceptable_iter: 3,
            ..Default::default()
        };
        assert!(!c.note_acceptable(true, false, ERR, OBJ));
        assert!(!c.note_acceptable(false, true, ERR, OBJ));
        assert_eq!(
            c.acceptable_count, 0,
            "a non-qualifying iterate resets the streak"
        );
        assert!(
            c.acceptable_window.is_empty(),
            "and clears the streak window"
        );
        assert!(!c.note_acceptable(true, false, ERR, OBJ));
        assert!(!c.note_acceptable(true, false, ERR, OBJ));
        assert!(
            c.note_acceptable(true, false, ERR, OBJ),
            "3 consecutive qualifying iterates terminate"
        );
    }

    /// gh #533. The reported `kissing` streak: fifteen iterates all inside the
    /// acceptable band, but with the KKT error wandering across it — the
    /// iterate the solve stopped on had an error an order of magnitude *worse*
    /// than one it had already reached in the same streak. The count alone
    /// stops there (objective `1.00000108`, `Solved_To_Acceptable_Level`);
    /// continuing reaches `0.84544259` with a strict certificate.
    #[test]
    fn a_wandering_streak_refuses_acceptable_termination() {
        // The tail of the reported trace (`main @ 880b360b`, default options):
        // inf_du 3.35e-08 → 8.18e-08 → 1.08e-07 → 4.15e-07 with the objective
        // flat to all eight printed figures throughout.
        let kissing_tail = [3.35e-08, 8.18e-08, 1.08e-07, 4.15e-07];
        let mut c = OptErrorConvCheck {
            acceptable_iter: 4,
            ..Default::default()
        };
        for (i, &err) in kissing_tail.iter().enumerate() {
            assert!(
                !c.note_acceptable(true, false, err, 1.0000011),
                "the streak must not terminate at iterate {i}: the error is still \
                 wandering across the acceptable band"
            );
        }
        assert!(
            c.acceptable_veto_fired,
            "the refusal must be recorded, or the run has nothing to fall back to"
        );
        assert!(
            !c.masked_acceptable_veto_fired,
            "a progress refusal is not a masked one and must not spend the masked \
             veto's budget"
        );
        // The count keeps running underneath the refusal — it is what identifies
        // the iterate the unvetoed run would have returned.
        assert_eq!(c.acceptable_count, 4);

        // Once the error settles, the window flattens — after the four-iterate
        // window has slid clear of the wandering tail — and the streak
        // terminates exactly as it would have without the mechanism.
        for _ in 0..2 {
            assert!(!c.note_acceptable(true, false, 4.15e-07, 1.0000011));
        }
        assert!(
            c.note_acceptable(true, false, 4.15e-07, 1.0000011),
            "a window of four identical iterates is settled; nothing is left to refuse"
        );
    }

    /// The other reported signal: `NARX_CFy`'s objective was still descending
    /// through the streak (`8.6579696e-03` at the stop, `8.6445195e-03` sixty
    /// iterations later) even where its error spread was small. Either signal
    /// alone must be enough to keep solving.
    #[test]
    fn a_still_descending_objective_refuses_acceptable_termination() {
        let mut c = OptErrorConvCheck {
            acceptable_iter: 4,
            ..Default::default()
        };
        // A perfectly steady error — only the objective is moving, by ~3e-6
        // over the window against a bar of 1e-1 · 1e-6 · max(1, |f|) = 1e-7.
        let objs = [8.6592e-03, 8.6588e-03, 8.6584e-03, 8.6580e-03];
        for (i, &f) in objs.iter().enumerate() {
            assert!(
                !c.note_acceptable(true, false, 1.5e-07, f),
                "the streak must not terminate at iterate {i}: the objective is still \
                 descending"
            );
        }
        assert!(c.acceptable_veto_fired);
    }

    /// The opt-out is real: `acceptable_progress_kappa = 0` restores the bare
    /// consecutive-count criterion, wandering error and all.
    #[test]
    fn zero_progress_kappa_restores_the_bare_count() {
        let mut c = OptErrorConvCheck {
            acceptable_iter: 4,
            acceptable_progress_kappa: 0.0,
            ..Default::default()
        };
        let kissing_tail = [3.35e-08, 8.18e-08, 1.08e-07, 4.15e-07];
        for (i, &err) in kissing_tail.iter().enumerate() {
            let terminated = c.note_acceptable(true, false, err, 1.0000011);
            assert_eq!(
                terminated,
                i == 3,
                "with the progress test off, iterate {i} must behave exactly as upstream"
            );
        }
        assert!(!c.acceptable_veto_fired);
    }

    /// The refusal budget bounds the cost of a solve that never settles: past
    /// [`ACCEPTABLE_PROGRESS_MAX_REFUSALS`] the test stands aside and the streak
    /// terminates as it would have without it, so the worst case is bounded
    /// extra iterations rather than a run to `max_iter`.
    #[test]
    fn the_progress_refusal_budget_is_bounded() {
        let mut c = OptErrorConvCheck {
            acceptable_iter: 2,
            ..Default::default()
        };
        // A permanent two-cycle inside the band: never flat, never converging.
        let mut terminated_at = None;
        for k in 0..(ACCEPTABLE_PROGRESS_MAX_REFUSALS + 10) {
            let err = if k % 2 == 0 { 1e-7 } else { 9e-7 };
            if c.note_acceptable(true, false, err, 1.0) {
                terminated_at = Some(k);
                break;
            }
        }
        assert_eq!(
            c.acceptable_progress_refusals, ACCEPTABLE_PROGRESS_MAX_REFUSALS,
            "the budget must be spent, not exceeded"
        );
        assert!(
            terminated_at.is_some(),
            "a never-settling solve must still terminate at the acceptable level once \
             the budget is spent"
        );
    }

    /// Flatness is judged over the streak's own window, and the window slides:
    /// a transient early in a solve must not block termination forever.
    #[test]
    fn the_flatness_window_slides_past_a_transient() {
        let mut c = OptErrorConvCheck {
            acceptable_iter: 3,
            ..Default::default()
        };
        // Entering the band while still descending: refused.
        assert!(!c.note_acceptable(true, false, 9e-7, 1.0));
        assert!(!c.note_acceptable(true, false, 5e-7, 1.0));
        assert!(!c.note_acceptable(true, false, 2e-7, 1.0));
        assert!(c.acceptable_veto_fired);
        // Then it plateaus. Two iterates later the descent has slid out of the
        // three-long window and the solve is judged settled.
        assert!(!c.note_acceptable(true, false, 2e-7, 1.0));
        assert!(
            c.note_acceptable(true, false, 2e-7, 1.0),
            "the window must slide, or an early transient blocks every later termination"
        );
    }

    /// `acceptable_iter = 1` asks to stop at the first qualifying iterate, and
    /// a one-iterate window carries no progress information — so the progress
    /// test must never refuse there.
    #[test]
    fn a_single_iterate_streak_carries_no_progress_signal() {
        let mut c = OptErrorConvCheck {
            acceptable_iter: 1,
            ..Default::default()
        };
        assert!(c.note_acceptable(true, false, 4.15e-07, 1.0));
        assert!(!c.acceptable_veto_fired);
    }

    /// A non-finite sample must not be read as movement — the mechanism spends
    /// iterations, so it may only fire on evidence it actually has.
    #[test]
    fn non_finite_samples_do_not_refuse() {
        for bad in [Number::NAN, Number::INFINITY] {
            let mut c = OptErrorConvCheck {
                acceptable_iter: 2,
                ..Default::default()
            };
            assert!(!c.note_acceptable(true, false, bad, 1.0));
            assert!(
                c.note_acceptable(true, false, 1e-7, 1.0),
                "a {bad} sample in the window must not be treated as a progress signal"
            );
        }
    }

    #[test]
    fn certificate_masked_needs_both_an_extreme_scale_and_a_non_stationary_point() {
        // gh #200. Both conditions are load-bearing, and each was independently
        // shown to be insufficient on the benchmark suite.
        let (th, atol) = (1e-4, 1e-6);

        // The reported failure: scale pinned at the 1e-8 floor, unscaled error
        // 0.84 — the strict test passed in scaled space at `quartc` obj 248.88.
        assert!(certificate_masked(1e-8, 8.4e-1, th, atol));

        // An ordinary objective scale is never second-guessed, however large
        // the unscaled error. Keying on the error alone effectively tightens
        // `tol` by `1/df` and regressed hs1/hs38 (scale ~4e-2).
        assert!(!certificate_masked(4e-2, 8.4e-1, th, atol));
        assert!(!certificate_masked(1.0, 1e3, th, atol));

        // An extreme scale at a point that really is stationary is fine — this
        // is what lifts the veto once the continued run reaches the minimum.
        assert!(!certificate_masked(1e-8, 1e-9, th, atol));

        // Boundaries: strictly below the scale threshold, strictly above the
        // error tolerance.
        assert!(!certificate_masked(th, 1.0, th, atol));
        assert!(!certificate_masked(1e-8, atol, th, atol));

        // `0` disables the mechanism outright (the documented opt-out) — the
        // most extreme possible inputs must not trip it.
        assert!(!certificate_masked(1e-30, 1e30, 0.0, atol));
        // A negative threshold is treated as disabled rather than as "always".
        assert!(!certificate_masked(1e-30, 1e30, -1.0, atol));
    }

    #[test]
    fn veto_blocks_both_strict_and_acceptable_termination() {
        // A refused strict certificate must not simply reappear as an
        // acceptable-level one at the same wrong point, so the veto covers both
        // branches. Exercised through the pure predicates the two branches
        // share, since a full `check_convergence_with_state` needs a live cq.
        let c = OptErrorConvCheck {
            tol: 1e-8,
            acceptable_tol: 1e-6,
            dual_inf_tol: 1.0,
            constr_viol_tol: 1e-4,
            compl_inf_tol: 1e-4,
            ..Default::default()
        };
        // The gh #200 iterate: passes the strict test in scaled space...
        assert!(c.passes_component_tols(1e-9, 8.4e-1, 0.0, 0.0, 0.0));
        // ...and the veto is what withholds it.
        assert!(certificate_masked(
            1e-8,
            8.4e-1,
            c.obj_scale_certificate_threshold,
            c.acceptable_tol
        ));
        // Default threshold is the documented 1e-4, and the veto starts clear.
        assert_eq!(c.obj_scale_certificate_threshold, 1e-4);
        assert!(!c.veto_fired);
        assert!(!ConvCheck::certificate_vetoed(&c));
    }

    #[test]
    fn passes_component_tols_requires_all_under_threshold() {
        let c = OptErrorConvCheck {
            tol: 1e-8,
            dual_inf_tol: 1.0,
            constr_viol_tol: 1e-4,
            compl_inf_tol: 1e-4,
            ..Default::default()
        };
        // All under threshold → converged.
        assert!(c.passes_component_tols(1e-9, 0.5, 1e-5, 1e-5, 0.0));
        // dual_inf above its tolerance blocks even when nlp_err is tiny.
        assert!(!c.passes_component_tols(1e-12, 2.0, 1e-5, 1e-5, 0.0));
        // compl_inf above its tolerance blocks.
        assert!(!c.passes_component_tols(1e-12, 0.0, 0.0, 1e-2, 0.0));
        // constr_viol above its tolerance blocks.
        assert!(!c.passes_component_tols(1e-12, 0.0, 1e-2, 0.0, 0.0));
    }

    #[test]
    fn infeasible_stationary_requires_violation_and_flat_gradient() {
        let c = OptErrorConvCheck {
            constr_viol_tol: 1e-4,
            infeas_viol_kappa: 1e2, // violation threshold = 1e-2
            infeas_stationarity_tol: 1e-8,
            infeas_max_streak: 5,
            ..Default::default()
        };
        // Violation well above 1e-2 and the infeasibility gradient
        // essentially zero → counts as infeasible-stationary.
        assert!(c.is_infeasible_stationary(1e-1, 0.0, 1e-9));
        // Violation above threshold but the gradient is not flat →
        // still making feasibility progress, does not count.
        assert!(!c.is_infeasible_stationary(1e-1, 0.0, 1e-3));
        // Gradient flat but violation below threshold → nearly
        // feasible, does not count.
        assert!(!c.is_infeasible_stationary(1e-3, 0.0, 1e-9));
    }

    /// gh #519: tightening `constr_viol_tol` must never widen the set of
    /// points the detector is willing to call infeasible. The absolute arm's
    /// floor used to be `infeas_viol_kappa · constr_viol_tol` unclamped, so at
    /// `constr_viol_tol = 1e-6` it fell to `1e-4` — and @bernalde's `f=1`
    /// model (gh #505), plateaued at an unscaled violation of `1.943e-4`, was
    /// convicted at a point its own run reported as acceptable.
    #[test]
    fn tightening_constr_viol_tol_never_arms_the_absolute_arm_lower() {
        let plateau_viol = 1.9430136821e-4; // the measured `f=1` plateau
        // Every value at or below the default: tightening from here must not
        // move the floor at all, and certainly not downward.
        for &cvt in &[1e-4, 1e-5, 1.94e-6, 1e-7, 1e-9, 1e-12] {
            let c = OptErrorConvCheck {
                constr_viol_tol: cvt,
                ..Default::default()
            };
            let floor = c.absolute_viol_threshold();
            assert_eq!(
                floor, MIN_INFEAS_VIOL_FLOOR,
                "constr_viol_tol={cvt} moved the absolute floor to {floor}"
            );
            // The `f=1` plateau is a nearly-feasible flat spot at every one
            // of these tolerances, so no `constr_viol_tol` may arm the
            // absolute arm on it (the relative signal is 0 here: the row is
            // unit-scale, so only the absolute arm is in play).
            assert!(
                !c.is_infeasible_stationary(plateau_viol, 0.0, 1e-9),
                "constr_viol_tol={cvt} armed the detector on the {plateau_viol} plateau"
            );
        }
    }

    /// The clamp is a floor, not a cap: `infeas_viol_kappa` still raises the
    /// absolute threshold, and a violation genuinely bounded away from
    /// feasible still arms the detector at any `constr_viol_tol`.
    #[test]
    fn absolute_viol_floor_is_a_floor_not_a_cap() {
        let strict = OptErrorConvCheck {
            constr_viol_tol: 1e-9,
            ..Default::default()
        };
        assert_eq!(strict.absolute_viol_threshold(), 1e-2);
        assert!(strict.is_infeasible_stationary(0.5, 0.0, 1e-9));
        // Raising kappa above the floor still moves the threshold.
        let wide = OptErrorConvCheck {
            constr_viol_tol: 1e-4,
            infeas_viol_kappa: 1e4, // 1e0, well above the 1e-2 floor
            ..Default::default()
        };
        assert_eq!(wide.absolute_viol_threshold(), 1.0);
        assert!(!wide.is_infeasible_stationary(0.5, 0.0, 1e-9));
        assert!(wide.is_infeasible_stationary(2.0, 0.0, 1e-9));
        // Loosening `constr_viol_tol` past the floor moves it too — the floor
        // only binds from below.
        let loose = OptErrorConvCheck {
            constr_viol_tol: 1e-2,
            ..Default::default()
        };
        assert_eq!(loose.absolute_viol_threshold(), 1.0);
    }

    /// gh #508: the status-decision sites that ask "is this violation real"
    /// read `constr_viol_tol` off the policy, so a user setting has to reach
    /// them — the defect was a threshold built from `tol` that no
    /// `constr_viol_tol` value could move. `set_tolerance` is the debugger's
    /// live hot-swap path and must be visible through the accessor too.
    #[test]
    fn constr_viol_tol_accessor_tracks_the_option() {
        let mut c = OptErrorConvCheck {
            tol: 1e-6,
            constr_viol_tol: 1e-3,
            ..Default::default()
        };
        assert_eq!(c.constr_viol_tol_or_default(), 1e-3);
        // Independent of `tol` — retuning convergence must not retune what
        // counts as a violated constraint.
        c.tol = 1e-10;
        assert_eq!(c.constr_viol_tol_or_default(), 1e-3);
        assert!(c.set_tolerance("constr_viol_tol", 1e-7));
        assert_eq!(c.constr_viol_tol_or_default(), 1e-7);
    }

    #[test]
    fn infeasible_stationary_disabled_by_nonpositive_knobs() {
        let off_tol = OptErrorConvCheck {
            infeas_stationarity_tol: 0.0,
            infeas_max_streak: 5,
            ..Default::default()
        };
        assert!(!off_tol.is_infeasible_stationary(1e9, 0.0, 0.0));
        let off_streak = OptErrorConvCheck {
            infeas_stationarity_tol: 1e-8,
            infeas_max_streak: 0,
            ..Default::default()
        };
        assert!(!off_streak.is_infeasible_stationary(1e9, 0.0, 0.0));
    }

    #[test]
    fn infeasible_stationary_streak_fires_only_after_max_streak() {
        let mut c = OptErrorConvCheck {
            constr_viol_tol: 1e-4,
            infeas_viol_kappa: 1e2, // violation threshold = 1e-2
            infeas_stationarity_tol: 1e-8,
            infeas_max_streak: 3,
            ..Default::default()
        };
        // Infeasible-stationary iterate: violation 1e-1 > 1e-2, flat
        // gradient. Streak accrues but does not fire until the third.
        assert!(!c.note_infeasible_stationary(1e-1, 0.0, 1e-9));
        assert!(!c.note_infeasible_stationary(1e-1, 0.0, 1e-9));
        assert!(c.note_infeasible_stationary(1e-1, 0.0, 1e-9));
    }

    #[test]
    fn infeasible_stationary_streak_resets_on_feasibility_progress() {
        let mut c = OptErrorConvCheck {
            constr_viol_tol: 1e-4,
            infeas_viol_kappa: 1e2,
            infeas_stationarity_tol: 1e-8,
            infeas_max_streak: 3,
            ..Default::default()
        };
        assert!(!c.note_infeasible_stationary(1e-1, 0.0, 1e-9));
        assert!(!c.note_infeasible_stationary(1e-1, 0.0, 1e-9));
        // A non-stationary iterate (gradient not flat) resets the streak.
        assert!(!c.note_infeasible_stationary(1e-1, 0.0, 1e-3));
        assert_eq!(c.infeas_streak, 0);
        // The streak must rebuild from scratch — no carry-over credit.
        assert!(!c.note_infeasible_stationary(1e-1, 0.0, 1e-9));
        assert!(!c.note_infeasible_stationary(1e-1, 0.0, 1e-9));
        assert!(c.note_infeasible_stationary(1e-1, 0.0, 1e-9));
    }

    #[test]
    fn infeasible_stationary_streak_never_fires_when_disabled() {
        let mut c = OptErrorConvCheck {
            infeas_stationarity_tol: 0.0,
            infeas_max_streak: 5,
            ..Default::default()
        };
        for _ in 0..20 {
            assert!(!c.note_infeasible_stationary(1e9, 0.0, 0.0));
        }
        assert_eq!(c.infeas_streak, 0);
    }

    /// gh #532. The scale-relative floor under `dual_inf_tol`, on the numbers
    /// that produced the report: `orthrds2` must pass, and the runaway
    /// `min -exp(x) s.t. x >= 0` must not.
    #[test]
    fn dual_inf_bound_forgives_a_relatively_stationary_residual_only() {
        let c = OptErrorConvCheck::new();
        assert_eq!(c.dual_inf_tol, 1.0);
        assert_eq!(c.dual_inf_scale_kappa, 1.0);

        // `orthrds2`: ‖∇L‖_∞ = 89.7 against terms of magnitude ~1.6e12 (the
        // mean multiplier magnitude behind its `s_d ≈ 1.6e10`) — stationary to
        // nine digits relative to what it is made of, and refused by the bare
        // `1.0` before the fix.
        let (orthrds2_dual_inf, orthrds2_scale) = (89.669_051_358_301_67, 1.6e12);
        assert!(orthrds2_dual_inf > c.dual_inf_tol, "the reported refusal");
        assert!(orthrds2_dual_inf <= c.dual_inf_bound(orthrds2_scale));
        assert!(c.passes_component_tols(
            5.537e-9,
            orthrds2_dual_inf,
            1.741e-8,
            0.0,
            orthrds2_scale
        ));

        // `min -exp(x) s.t. x >= 0` running away: `∇f = −8.8e47` with no
        // multiplier to meet it, so nothing cancelled and the residual IS the
        // scale. Refused by eight orders — the case any such rule has to keep
        // rejecting.
        let runaway = 8.8e47;
        assert!(runaway > c.dual_inf_bound(runaway));
        assert!(!c.passes_component_tols(1e-12, runaway, 1.7e-10, 0.0, runaway));

        // The floor is a floor, never a tightening: below `dual_inf_tol` the
        // absolute arm decides, at any scale.
        assert_eq!(c.dual_inf_bound(1.0), c.dual_inf_tol);
        assert_eq!(c.dual_inf_bound(0.0), c.dual_inf_tol);
        assert_eq!(c.dual_inf_bound(1e-30), c.dual_inf_tol);
        // ...and it only lifts off `dual_inf_tol` once the scale passes
        // `dual_inf_tol / (kappa · tol)` = 1e8, so every `O(1)` model keeps the
        // upstream comparison bit for bit.
        assert_eq!(c.dual_inf_bound(1e7), c.dual_inf_tol);
        assert!(c.dual_inf_bound(1e10) > c.dual_inf_tol);

        // Non-finite scales say nothing and must not widen anything.
        for bad in [Number::NAN, Number::INFINITY, Number::NEG_INFINITY] {
            assert_eq!(c.dual_inf_bound(bad), c.dual_inf_tol, "scale {bad}");
        }
    }

    /// The floor tracks `tol`: asking for a stricter solve tightens the dual
    /// component gate in proportion, and `dual_inf_scale_kappa = 0` is the
    /// documented opt-out back to upstream's bare absolute bound.
    #[test]
    fn dual_inf_bound_tracks_tol_and_honours_the_opt_out() {
        let mut c = OptErrorConvCheck::new();
        assert_eq!(c.dual_inf_bound(1e12), 1e4);
        c.tol = 1e-10;
        assert_eq!(c.dual_inf_bound(1e12), 1e2);
        // Kappa scales the floor as advertised.
        c.tol = 1e-8;
        c.dual_inf_scale_kappa = 10.0;
        assert_eq!(c.dual_inf_bound(1e12), 1e5);
        // `0` (and, defensively, a negative or NaN value the option's own lower
        // bound already refuses) disables it outright — the most extreme scale
        // must not move the bound.
        for off in [0.0, -1.0, Number::NAN] {
            c.dual_inf_scale_kappa = off;
            assert_eq!(c.dual_inf_bound(1e30), c.dual_inf_tol, "kappa {off}");
            assert!(!c.passes_component_tols(1e-12, 89.7, 0.0, 0.0, 1.6e12));
        }
    }

    /// gh #528. The strict gate reads the noise-floored aggregate when that is
    /// the smaller of the two, and is otherwise untouched — the floored value
    /// can never *raise* the error.
    #[test]
    fn strict_overall_takes_the_noise_floored_aggregate() {
        // The reported case: KKT error pinned one ulp of `|b| ~ 1e8` above
        // `tol`, with the primal residual entirely inside its own resolution.
        assert_eq!(
            OptErrorConvCheck::strict_overall(1.49e-8, 9.09e-10),
            9.09e-10
        );
        // Nothing at its resolution limit: the two agree and the gate is the
        // upstream one, bit for bit.
        assert_eq!(OptErrorConvCheck::strict_overall(1e-9, 1e-9), 1e-9);
    }

    /// A non-finite KKT error must survive the floor untouched. `f64::min`
    /// returns the *other* operand at `NaN`, so a bare `min` would launder the
    /// `Invalid_Number_Detected` signal `curr_nlp_error`'s `has_valid_numbers`
    /// sweep exists to raise (gh #292).
    #[test]
    fn strict_overall_passes_a_non_finite_error_through() {
        assert!(OptErrorConvCheck::strict_overall(Number::NAN, 1e-12).is_nan());
        assert_eq!(
            OptErrorConvCheck::strict_overall(Number::INFINITY, 1e-12),
            Number::INFINITY
        );
    }

    #[test]
    fn max_iter_exceeded() {
        let mut c = OptErrorConvCheck {
            max_iter: 5,
            ..Default::default()
        };
        assert_eq!(
            c.check_convergence(1.0, 5),
            ConvergenceStatus::MaxIterExceeded
        );
    }
}