4343
4444from tallyman_core import aliases
4545from tallyman_core .entry_config import carry_forward_entry_config
46- from tallyman_core .errors import error_for_hash , record_error
46+ from tallyman_core .errors import errors_by_hash , record_error
4747from tallyman_core .paths import entry_dir , entry_manifest_path , project_dir
4848from tallyman_xorq .build import BuildError , build_and_persist
4949from tallyman_xorq .dependents import DependencyCycleError , build_dag , descendant_cone
@@ -169,7 +169,13 @@ def recalc(project: str, roots: list[str], *, dry_run: bool = False) -> RecalcRe
169169 return report
170170
171171
172- def _replay_cone (project : str , roots : list [str ]) -> RecalcReport :
172+ def _replay_cone (
173+ project : str ,
174+ roots : list [str ],
175+ * ,
176+ verdicts : dict [str , StaleVerdict ] | None = None ,
177+ tool : str = "catalog_recalc" ,
178+ ) -> RecalcReport :
173179 """Replay *roots* and their cone in dependency order, **without** taking a
174180 checkpoint — the reusable engine behind both the public ``recalc`` and the
175181 auto-recalc folded into ``catalog_revise``.
@@ -182,14 +188,20 @@ def _replay_cone(project: str, roots: list[str]) -> RecalcReport:
182188 for the caller's single checkpoint to commit. Returns a report with
183189 ``checkpoint_step=None`` — the *caller* owns the one checkpoint (``recalc``
184190 for an explicit run; the per-op decorator/middleware for an embedded one).
191+
192+ ``verdicts`` lets a caller that already scanned (``auto_recalc``) hand them in
193+ so the walk doesn't re-scan; ``tool`` names the surface that triggered the
194+ walk, recorded on each persisted failure so the forensic record names the tool
195+ that actually ran (not always ``catalog_recalc``).
185196 """
186197 roots = [r for r in roots if entry_manifest_path (project , r ).exists ()]
187198 try :
188199 cone = descendant_cone (project , roots )
189200 except DependencyCycleError as exc :
190201 return RecalcReport (project , roots , [], [], False , "cycle" , error = str (exc ))
191202
192- verdicts = scan (project )
203+ if verdicts is None :
204+ verdicts = scan (project )
193205 remap : dict [str , str ] = {}
194206 entries : list [RecalcEntry ] = []
195207 status = "ok"
@@ -205,7 +217,7 @@ def _replay_cone(project: str, roots: list[str]) -> RecalcReport:
205217 project ,
206218 code = _recipe_code (project , old_hash ),
207219 message = "skipped: an upstream build failure halted the recalc walk" ,
208- tool = "catalog_recalc" ,
220+ tool = tool ,
209221 hash = old_hash ,
210222 )
211223 entries .append (_entry (old_hash , "skipped" , verdict , project ))
@@ -222,7 +234,7 @@ def _replay_cone(project: str, roots: list[str]) -> RecalcReport:
222234 project ,
223235 code = _recipe_code (project , old_hash ),
224236 message = str (exc ),
225- tool = "catalog_recalc" ,
237+ tool = tool ,
226238 hash = old_hash ,
227239 )
228240 entries .append (
@@ -272,22 +284,41 @@ def followers_of(project: str, name: str, verdicts: dict[str, StaleVerdict] | No
272284 )
273285
274286
275- def _classify_orphan (project : str , content_hash : str , verdict : StaleVerdict ) -> dict :
287+ def _alias_heads_index (project : str ) -> dict [str , list [str ]]:
288+ """``{content_hash: [alias, ...]}`` over the current alias heads, built once.
289+
290+ The reverse of ``_aliases_heading`` for a batch caller (``classify_orphans``)
291+ so classifying N orphans loads ``aliases.jsonl`` a single time, not N times."""
292+ index : dict [str , list [str ]] = {}
293+ for name , latest in aliases .load_aliases (project ).items ():
294+ index .setdefault (latest , []).append (name )
295+ for names in index .values ():
296+ names .sort ()
297+ return index
298+
299+
300+ def _classify_orphan (
301+ content_hash : str ,
302+ verdict : StaleVerdict ,
303+ errors_index : dict [str , dict ],
304+ alias_index : dict [str , list [str ]],
305+ ) -> dict :
276306 """Classify a stale entry an auto-recalc did *not* recompute: a self-referential
277307 pin (expected), explained by a recorded failure, or UNEXPLAINED.
278308
279309 Self-reference is checked first and only when it is the SOLE cause (every reason
280310 is the entry following its own head): an entry that is also source- or
281- alias-drifted for a real reason still classifies normally."""
311+ alias-drifted for a real reason still classifies normally. ``errors_index`` /
312+ ``alias_index`` are the prebuilt one-pass lookups (see ``classify_orphans``)."""
282313 if verdict .reasons and all (_is_self_ref (content_hash , r ) for r in verdict .reasons ):
283314 explanation = "self-referential revise pin (follows its own alias head); permanently stale by design — expected"
284- elif (err := error_for_hash ( project , content_hash )) is not None :
315+ elif (err := errors_index . get ( content_hash )) is not None :
285316 explanation = f"explained by error { err ['id' ]} "
286317 else :
287318 explanation = "UNEXPLAINED stale entry — staleness with no recorded recalc error; file a bug."
288319 return {
289320 "hash" : content_hash ,
290- "alias" : (_aliases_heading ( project , content_hash ) or [None ])[0 ],
321+ "alias" : (alias_index . get ( content_hash ) or [None ])[0 ],
291322 "reasons" : [asdict (r ) for r in verdict .reasons ],
292323 "explanation" : explanation ,
293324 }
@@ -303,12 +334,20 @@ def classify_orphans(
303334 rebuilt as *recomputed*, so only the leftovers are classified. The
304335 scan-on-load surfaces pass nothing: at scan time no cascade ran, so every
305336 directly-stale entry is an orphan to be accounted for (the D5 backstop —
306- a project load surfaces orphans even when no revise has happened since)."""
337+ a project load surfaces orphans even when no revise has happened since).
338+
339+ The error store and alias heads are read once up front (``errors_by_hash`` /
340+ ``_alias_heads_index``) and shared across all orphans, so the pass is one log
341+ read + one aliases read regardless of orphan count."""
307342 orphans = sorted (h for h , v in verdicts .items () if v .stale and h not in recomputed )
308- return [_classify_orphan (project , h , verdicts [h ]) for h in orphans ]
343+ if not orphans :
344+ return []
345+ errors_index = errors_by_hash (project )
346+ alias_index = _alias_heads_index (project )
347+ return [_classify_orphan (h , verdicts [h ], errors_index , alias_index ) for h in orphans ]
309348
310349
311- def auto_recalc (project : str , name : str ) -> dict :
350+ def auto_recalc (project : str , name : str , * , tool : str = "catalog_revise" ) -> dict :
312351 """Recompute the followers a revise of alias *name* made stale, **without**
313352 taking a checkpoint, and account for every other stale entry it did not touch.
314353
@@ -318,14 +357,20 @@ def auto_recalc(project: str, name: str) -> dict:
318357 yet recalced, or an invariant break) — left untouched, logged at WARNING, and
319358 returned in ``orphan_stale`` classified against the error store (#6).
320359
360+ ``tool`` names the surface that triggered this auto-recalc (``catalog_revise``,
361+ ``catalog_promote_diff``, the companion routes); it is recorded on any persisted
362+ cascade failure so the forensic record names the tool that actually ran.
363+
321364 Returns the recalc report (``catalog_recalc``'s shape) plus ``orphan_stale``.
322365 The caller embeds this in its own transaction: it owns the single checkpoint
323366 and the cross-process notify / SSE publish.
324367 """
325368 verdicts = scan (project )
326369 roots = followers_of (project , name , verdicts )
327370
328- report = _replay_cone (project , roots ) # checkpoint-free; caller commits
371+ # Reuse the verdicts just scanned (one scan per revise, not two) and tag any
372+ # persisted failure with the triggering tool.
373+ report = _replay_cone (project , roots , verdicts = verdicts , tool = tool ) # checkpoint-free; caller commits
329374
330375 orphan_stale = classify_orphans (project , verdicts , recomputed = frozenset (report .cone ))
331376 for o in orphan_stale :
0 commit comments