Skip to content

Commit bbceff6

Browse files
Jammy2211claude
andauthored
feat: report prior-support clipping in search.summary (#1478)
The Clipper (#1477) counts how often it fires -- n_clipped_lane_steps is accumulated per-lane in AbstractMultiStartGradient and written to search_internal -- but the count never reached the artefact a user actually reads to find out what a search did. search.summary said nothing about clipping at all. The channel already existed: search_summary_from_samples reads samples.samples_info and emits the NaN counters and their rates, guarded on the key so searches without them are unaffected. Two things were missing from it rather than a mechanism: - n_clipped_lane_steps never reached samples_info. It stopped at search_internal, so nothing downstream could see it. - n_constrained_lane_steps did reach samples_info but was never emitted, so the trapped-lane counter from #1475 has been invisible in search.summary since it shipped. Now reported. Clipping is reported in three cases, and the distinction between the last two is the point: - No clipper (ClipperNone, or a search predating the Clipper): emits nothing. The default path's summary is unchanged, which matters because this file is read by tooling and sits in every archived run's output. - Clipped and counted (MultiStartGradient): it enforces the constraint itself every step via Clipper.project, so it knows how often it fired and reports the count and the rate, denominated by n_starts * total_steps like the NaN rates beside it. - Clipped but not observable (LBFGS and the bound-supporting scipy methods): declarative, handing optimize.Bounds to scipy and letting scipy enforce, so project is never called and no mask exists. Reporting 0 there would read as "the clipper never fired" when it means "this search cannot know", so it says "not measured (bounds enforced by scipy)" instead. The clipper is published as its class NAME rather than a bool, so the summary can say which strategy ran and a later strategy needs no schema change. The count is per-LANE, not per-coordinate -- a lane clipped in three parameters on one step is one clipped lane-step -- matching how the counters beside it read, so all four stay directly comparable. It is restored from search_internal as a lifetime total, so a resumed run reports the whole run's clipping rather than the current process's share. Verified end-to-end against the search.summary files four real searches wrote, not just the formatting helper in isolation: LBFGS default (no clipping lines), LBFGS clipped ("not measured"), MultiStart default (no clipping lines), MultiStart clipped (Clipped Lane-Steps = 414, rate 0.958). One behaviour change to note: multi-start summaries gain a Constrained Lane-Steps line they did not have before. Everything else is additive and gated. Claude-Session: https://claude.ai/code/session_01FzF2XmKQaqZRWZfZMxvTNR Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1f4b66a commit bbceff6

4 files changed

Lines changed: 243 additions & 0 deletions

File tree

autofit/non_linear/search/mle/bfgs/search.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,15 @@ def samples_via_internal_from(
354354

355355
samples_info = {
356356
"total_iterations": total_iterations,
357+
# Prior-support enforcement (PyAutoFit#1477). The name only, with
358+
# deliberately NO ``n_clipped_lane_steps`` alongside it: this search
359+
# is declarative, handing ``optimize.Bounds`` to scipy and letting
360+
# scipy enforce, so ``Clipper.project`` is never called, no mask is
361+
# produced and there is nothing to count. Writing a ``0`` here would
362+
# read as "the clipper never fired" when it means "this search
363+
# cannot know"; the summary renders the absent key as "not measured"
364+
# instead.
365+
"clipper": type(self.clipper).__name__,
357366
"time": self.timer.time if self.timer else None,
358367
}
359368

autofit/non_linear/search/mle/multi_start_gradient/search.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1168,6 +1168,21 @@ def samples_via_internal_from(
11681168
"n_grad_nan_lane_steps": int(
11691169
search_internal.get("n_grad_nan_lane_steps", 0)
11701170
),
1171+
# Prior-support enforcement (PyAutoFit#1477). The clipper's NAME
1172+
# rather than a bool, so the summary can say which strategy ran and
1173+
# so a later strategy needs no schema change here.
1174+
#
1175+
# ``n_clipped_lane_steps`` is per-LANE, matching the counters above:
1176+
# a lane clipped in three parameters on one step is one clipped
1177+
# lane-step, which keeps all four directly comparable. It is
1178+
# restored from ``search_internal`` as a lifetime total, so a
1179+
# resumed run reports the whole run's clipping and not just the
1180+
# current process's share — the same reasoning as the ``.get``
1181+
# defaults above.
1182+
"clipper": type(self.clipper).__name__,
1183+
"n_clipped_lane_steps": int(
1184+
search_internal.get("n_clipped_lane_steps", 0)
1185+
),
11711186
# Auto-convergence outcome: whether the run stopped on the plateau
11721187
# check ("converged") or exhausted the ``n_steps`` ceiling
11731188
# ("max_steps"), the settings that produced it, and the global-best

autofit/text/text_util.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,61 @@ def result_info_from(samples) -> str:
112112
return "".join(results)
113113

114114

115+
#: The clipper that enforces nothing. A run using it is the default, unclipped
116+
#: path, and reports no clipping lines at all — see ``_clipper_summary_from``.
117+
_CLIPPER_NONE = "ClipperNone"
118+
119+
120+
def _clipper_summary_from(samples_info) -> [str]:
121+
"""
122+
The ``search.summary`` lines describing prior-support enforcement, or none.
123+
124+
Three cases, and the distinction between the last two is the point:
125+
126+
- **No clipper configured** (``ClipperNone``, or a search that predates the
127+
``Clipper`` and writes no ``clipper`` key). Emits **nothing**. The default
128+
path's summary is unchanged, byte for byte, which matters because this
129+
file is read by tooling and by every existing run's archived output.
130+
- **Clipped, and counted** (``MultiStartGradient``). It enforces the
131+
constraint itself via ``Clipper.project`` on every step, so it knows
132+
exactly how often it fired and reports the count and the rate.
133+
- **Clipped, but not observable** (``LBFGS`` and the other bound-supporting
134+
scipy methods). These are *declarative*: they hand ``optimize.Bounds`` to
135+
scipy and let scipy enforce, so ``project`` is never called, no mask is
136+
produced and there is nothing to count. Reporting ``0`` here would be a
137+
lie of the worst kind available — it reads as "the clipper never fired"
138+
when it means "this search cannot know". It says so instead.
139+
140+
The count is per-LANE, not per-coordinate: a lane clipped in three
141+
parameters on one step is one clipped lane-step. That is deliberate, and it
142+
matches how the NaN and constrained counters above are read, so the four are
143+
directly comparable.
144+
"""
145+
clipper = samples_info.get("clipper")
146+
147+
if clipper is None or clipper == _CLIPPER_NONE:
148+
return []
149+
150+
line = [f"Clipper = {clipper}\n"]
151+
152+
if "n_clipped_lane_steps" not in samples_info:
153+
line.append(
154+
"Clipped Lane-Steps = not measured (bounds enforced by scipy)\n"
155+
)
156+
return line
157+
158+
n_clipped = int(samples_info["n_clipped_lane_steps"])
159+
line.append(f"Clipped Lane-Steps = {n_clipped}\n")
160+
161+
lane_steps = int(samples_info.get("n_starts", 0)) * int(
162+
samples_info.get("total_steps", 0)
163+
)
164+
if lane_steps > 0:
165+
line.append(f"Clipped Lane-Step Rate = {n_clipped / lane_steps}\n")
166+
167+
return line
168+
169+
115170
def search_summary_from_samples(samples) -> [str]:
116171
line = [f"Total Samples = {samples.total_samples}\n"]
117172
if hasattr(samples, "total_accepted_samples"):
@@ -143,6 +198,16 @@ def search_summary_from_samples(samples) -> [str]:
143198
line.append(f"Value-NaN Lane-Steps = {n_value_nan}\n")
144199
line.append(f"Gradient-NaN Lane-Steps = {n_grad_nan}\n")
145200

201+
# The trapped-lane counter (PyAutoFit#1475). It reached ``samples_info``
202+
# when it shipped but was never emitted here, so the one artefact a user
203+
# reads to find out what the search did did not report it. Keyed
204+
# separately from the NaN counters because a ``search_internal`` written
205+
# before it existed has no such key, and a zero it never wrote must not
206+
# be reported as a measured zero.
207+
if "n_constrained_lane_steps" in samples_info:
208+
n_constrained = int(samples_info["n_constrained_lane_steps"])
209+
line.append(f"Constrained Lane-Steps = {n_constrained}\n")
210+
146211
# ``n_starts * total_steps`` is the number of lane-steps actually taken.
147212
# A search that died before its first step has a zero denominator, so
148213
# the rates are omitted rather than reported as a division error.
@@ -153,6 +218,8 @@ def search_summary_from_samples(samples) -> [str]:
153218
line.append(f"Value-NaN Lane-Step Rate = {n_value_nan / lane_steps}\n")
154219
line.append(f"Gradient-NaN Lane-Step Rate = {n_grad_nan / lane_steps}\n")
155220

221+
line += _clipper_summary_from(samples_info=samples_info)
222+
156223
if samples.time is not None:
157224
line.append(f"Time To Run = {dt.timedelta(seconds=float(samples.time))}\n")
158225
line.append(

test_autofit/text/test_text_util.py

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,3 +213,155 @@ def test__search_summary__nan_rates_omitted_when_no_lane_steps_taken(model):
213213

214214
assert "Value-NaN Lane-Steps = 0\n" in summary
215215
assert "Rate" not in summary
216+
217+
218+
def test__search_summary__clipper_none_emits_nothing(model):
219+
"""
220+
The default path must be untouched. A ``ClipperNone`` run reports no
221+
clipping lines at all -- this file is read by tooling and sits in every
222+
archived run's output, so the unclipped summary stays byte-identical.
223+
"""
224+
samples = _gradient_samples(
225+
model=model,
226+
samples_info={
227+
"total_samples": 10,
228+
"time": None,
229+
"n_starts": 8,
230+
"total_steps": 100,
231+
"n_resurrections": 0,
232+
"n_value_nan_lane_steps": 0,
233+
"n_grad_nan_lane_steps": 0,
234+
"clipper": "ClipperNone",
235+
"n_clipped_lane_steps": 0,
236+
},
237+
)
238+
239+
summary = "".join(text_util.search_summary_from_samples(samples=samples))
240+
241+
assert "Clipper" not in summary
242+
assert "Clipped" not in summary
243+
244+
245+
def test__search_summary__clipper_counted_reports_count_and_rate(model):
246+
"""
247+
``MultiStartGradient`` enforces the constraint itself every step, so it
248+
knows how often the clipper fired and reports the count and the rate.
249+
"""
250+
samples = _gradient_samples(
251+
model=model,
252+
samples_info={
253+
"total_samples": 10,
254+
"time": None,
255+
"n_starts": 8,
256+
"total_steps": 100,
257+
"n_resurrections": 0,
258+
"n_value_nan_lane_steps": 0,
259+
"n_grad_nan_lane_steps": 0,
260+
"clipper": "ClipperPriorBox",
261+
"n_clipped_lane_steps": 40,
262+
},
263+
)
264+
265+
summary = "".join(text_util.search_summary_from_samples(samples=samples))
266+
267+
assert "Clipper = ClipperPriorBox\n" in summary
268+
assert "Clipped Lane-Steps = 40\n" in summary
269+
270+
# 40 / (8 * 100), the same lane-step denominator as the NaN rates.
271+
assert "Clipped Lane-Step Rate = 0.05\n" in summary
272+
273+
274+
def test__search_summary__clipper_without_count_says_not_measured(model):
275+
"""
276+
``LBFGS`` is declarative -- it hands bounds to scipy and never calls
277+
``project``, so there is no mask and nothing to count. A ``0`` here would
278+
read as "never fired" when it means "cannot know", so it must not appear.
279+
"""
280+
samples = _gradient_samples(
281+
model=model,
282+
samples_info={
283+
"total_samples": 10,
284+
"time": None,
285+
"clipper": "ClipperPriorBox",
286+
},
287+
)
288+
289+
summary = "".join(text_util.search_summary_from_samples(samples=samples))
290+
291+
assert "Clipper = ClipperPriorBox\n" in summary
292+
assert "Clipped Lane-Steps = not measured (bounds enforced by scipy)\n" in summary
293+
assert "Clipped Lane-Steps = 0" not in summary
294+
assert "Clipped Lane-Step Rate" not in summary
295+
296+
297+
def test__search_summary__clipper_absent_for_other_searches(model):
298+
"""
299+
A search predating the ``Clipper`` writes no ``clipper`` key, and must be
300+
completely unaffected -- the same invariant the NaN counters hold.
301+
"""
302+
samples = _gradient_samples(
303+
model=model,
304+
samples_info={
305+
"total_samples": 10,
306+
"time": None,
307+
"n_starts": 8,
308+
"total_steps": 100,
309+
"n_resurrections": 0,
310+
"n_value_nan_lane_steps": 0,
311+
"n_grad_nan_lane_steps": 0,
312+
},
313+
)
314+
315+
summary = "".join(text_util.search_summary_from_samples(samples=samples))
316+
317+
assert "Clipper" not in summary
318+
assert "Clipped" not in summary
319+
320+
321+
def test__search_summary__constrained_lane_steps_reported(model):
322+
"""
323+
The trapped-lane counter (PyAutoFit#1475) reached ``samples_info`` when it
324+
shipped but was never emitted, so the artefact a user reads to find out what
325+
the search did did not report it.
326+
"""
327+
samples = _gradient_samples(
328+
model=model,
329+
samples_info={
330+
"total_samples": 10,
331+
"time": None,
332+
"n_starts": 8,
333+
"total_steps": 100,
334+
"n_resurrections": 0,
335+
"n_value_nan_lane_steps": 0,
336+
"n_grad_nan_lane_steps": 0,
337+
"n_constrained_lane_steps": 667,
338+
},
339+
)
340+
341+
summary = "".join(text_util.search_summary_from_samples(samples=samples))
342+
343+
assert "Constrained Lane-Steps = 667\n" in summary
344+
345+
346+
def test__search_summary__constrained_absent_when_never_written(model):
347+
"""
348+
A ``search_internal`` written before the trapped-lane counter existed has no
349+
such key. A zero it never wrote must not be reported as a measured zero --
350+
``0`` and "not written" are different findings.
351+
"""
352+
samples = _gradient_samples(
353+
model=model,
354+
samples_info={
355+
"total_samples": 10,
356+
"time": None,
357+
"n_starts": 8,
358+
"total_steps": 100,
359+
"n_resurrections": 0,
360+
"n_value_nan_lane_steps": 0,
361+
"n_grad_nan_lane_steps": 0,
362+
},
363+
)
364+
365+
summary = "".join(text_util.search_summary_from_samples(samples=samples))
366+
367+
assert "Constrained" not in summary

0 commit comments

Comments
 (0)