An altpenalty compared against the constant 0 (altpenalty X > 0, X >= 0, 0 < X) is silently ignored, so a failed constraint is charged weight times its primary violation instead
メンテナーはふだん 1 日以内に返信
まだ誰も着手していません。
評価
調査の方向性
pybnf/constraint.py:338-347 の altpenalty の正規化を読み、その後、689 行目の get_static_penalty と 889 行目の _static_penalty_gradient にあるチェックを調べてください。提供された obs.prop と sim.gdat の例で再現し、penalty と gradient の両方のパスがゼロ定数を処理できることを確認してください。影響を受けるケースが記載された正しい penalty を生成し、既存の制御が変更されないことを確認できれば完了です。
索引モデルが issue の本文から書いたものです。
説明
load_constraint_file turns a numeric side of the altpenalty inequality into a float (pybnf/constraint.py:113-117). Constraint.init then rewrites a >/>= altpenalty as < by swapping its sides (pybnf/constraint.py:338-347). After both steps, altpenalty X > 0, X >= 0, 0 < X and 0 <= X all store alt1 = 0.0, alt2 = 'X'. get_static_penalty uses if self.alt1: (pybnf/constraint.py:689) to decide whether an altpenalty is present. Because 0.0 is falsy, the altpenalty branch is skipped and the penalty becomes weight times the primary inequality's violation. _static_penalty_gradient runs the same test (pybnf/constraint.py:889), so the gradient matches the wrong objective and does not expose the problem. find_keys accepts a float alt1 without complaint (pybnf/constraint.py:421-422, where get_key returns None). No error, warning or log line appears. altpenalty X < 0 is not affected, because there the 0 ends up in alt2.
Failure scenario
docs/config.rst:251 defines the altpenalty rule. When the primary inequality fails, the penalty is weight * max(0, alt1 - alt2). If the altpenalty inequality holds, the penalty is weight * min, or 0 when no min is set. Take A < 1 at 1 weight 10 altpenalty X > 0 with A = 5:
- X = -3: the penalty should be 10 * max(0, 0 - (-3)) = 30. PyBNF returns 40, which is 10 times the primary violation 5 - 1.
- X = +3: the altpenalty inequality holds, so the penalty should be 0. PyBNF returns 40.
- X = +3 with
min 1: the penalty should be 10. PyBNF returns 40.
The error runs in both directions. A parameter set that satisfies the continuous proxy is still charged, and the penalty follows A instead of X, which defeats the purpose of the substitution.
Reproduction
Run from any empty directory with PyBNF importable:
from pybnf import data, constraint
lines = ['A < 1 at 1 weight 10 altpenalty X > 0',
'A < 1 at 1 weight 10 altpenalty 0 < X',
'A < 1 at 1 weight 10 altpenalty X >= 0',
'A < 1 always weight 10 altpenalty X > 0',
'A < 1 at 1 weight 10 altpenalty X > 0 min 1',
'A < 1 at 1 weight 10 altpenalty X > 1e-300 min 1', # control
'A < 1 at 1 weight 10 altpenalty X > 0.5'] # control
with open('obs.prop', 'w') as f:
f.write('\n'.join(lines) + '\n')
for x in (-3, 3):
with open('sim.gdat', 'w') as f:
f.write(f'# time A X\n0 5 {x}\n1 5 {x}\n')
d = data.Data()
d.load_data('sim.gdat')
cs = constraint.ConstraintSet('model', 'obs')
cs.load_constraint_file('obs.prop')
for line, c in zip(lines, cs.constraints):
print(f'X={x:+d} {line:50s} alt1={c.alt1!r:7} penalty={c.penalty({"model": {"obs": d}})}')
| constraint | X | PyBNF | correct |
|---|---|---|---|
altpenalty X > 0 |
-3 | 40.0 | 30 |
altpenalty 0 < X |
-3 | 40.0 | 30 |
altpenalty X >= 0 |
-3 | 40.0 | 30 |
always ... altpenalty X > 0 |
-3 | 40.0 | 30 |
altpenalty X > 0 min 1 |
-3 | 40.0 | 30 |
altpenalty X > 1e-300 min 1 (control) |
-3 | 30.0 | 30 |
altpenalty X > 0.5 (control) |
-3 | 35.0 | 35 |
altpenalty X > 0 |
+3 | 40.0 | 0 |
altpenalty 0 < X |
+3 | 40.0 | 0 |
altpenalty X >= 0 |
+3 | 40.0 | 0 |
always ... altpenalty X > 0 |
+3 | 40.0 | 0 |
altpenalty X > 0 min 1 |
+3 | 40.0 | 10 |
altpenalty X > 1e-300 min 1 (control) |
+3 | 10.0 | 10 |
altpenalty X > 0.5 (control) |
+3 | 0.0 | 0 |
Every affected line prints alt1=0.0. Moving the constant only to 1e-300 gives the correct value.
Reachability
This affects any .prop constraint file (qualitative data read through exp_file / data:) with a weighted constraint whose altpenalty has 0 as its constant side after normalization: X > 0, X >= 0, 0 < X or 0 <= X. It applies to at, between, always and once constraints. The altpenalty grammar accepts a number on either side (pybnf/constraint.py:249-251, :264), and X > 0 is the natural way to say that the continuous proxy should be positive. Nothing upstream rejects the input and nothing downstream corrects it. Gradient-based fits get a gradient consistent with the wrong penalty.
Where
pybnf/constraint.py:689 pybnf/constraint.py:889 pybnf/constraint.py:338-347
The obvious fix is if self.alt1 is not None: at both test sites.
Related: #890, #887.
Found in a whole-codebase audit for silently wrong results (2026-09-23); the reproduction above was re-run independently of the original finding.
- 主要言語
- Python
- スター
- 25
- フォーク
- 25
- 平均マージ
- 3時間 32分
- マージ済み PR(30日)
- 96
環境構築
はじめの一歩
- issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
- 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
- リポジトリをフォークし、ブランチを切って変更します。
- issue 番号を参照したプルリクエストを送ります。
lanl/PyBNF のほかの issue
-
bug silent-incorrectness
難易度 2/5 1〜3時間 初心者へのやさしさ 78/100
メンテナーはふだん 1 日以内に返信
-
bug
難易度 2/5 1〜3時間 初心者へのやさしさ 82/100
メンテナーはふだん 1 日以内に返信
-
documentation
難易度 1/5 1時間未満 初心者へのやさしさ 91/100
メンテナーはふだん 1 日以内に返信
-
bug silent-incorrectness
難易度 2/5 1〜3時間 初心者へのやさしさ 82/100
メンテナーはふだん 1 日以内に返信
-
bug silent-incorrectness
難易度 2/5 1〜3時間 初心者へのやさしさ 76/100
メンテナーはふだん 1 日以内に返信
似ている issue
-
bug
難易度 2/5 1〜3時間 初心者へのやさしさ 85/100
メンテナーはふだん 1 日以内に返信
-
難易度 1/5 1時間未満 初心者へのやさしさ 90/100
メンテナーはふだん 1 日以内に返信
-
instance instance add
難易度 2/5 1〜3時間 初心者へのやさしさ 68/100
searxng/searx-instances#941 · コメント 1 件 ·
-
難易度 1/5 1時間未満 初心者へのやさしさ 92/100
FluidNumerics/fluid-walk-blocker#89 ·
メンテナーはふだん 1 日以内に返信
-
bug
難易度 2/5 1〜3時間 初心者へのやさしさ 84/100
メンテナーはふだん 1 日以内に返信