Skip to content

Commit d45be13

Browse files
authored
Merge pull request #155 from buckaroo-data/fix/154-head-reachable-staleness
fix(#154): gate staleness on head-reachability — no husk replay, no false orphans
2 parents 4303a0d + a5a8746 commit d45be13

6 files changed

Lines changed: 191 additions & 12 deletions

File tree

src/tallyman_companion/app.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1671,7 +1671,9 @@ def api_staleness(project: str):
16711671
Read-only (no checkpoint). Drives the per-entry staleness badge: ``stale``
16721672
lists directly-stale hashes (their own input moved), ``transitively_stale``
16731673
the clean descendants a recalc would carry, ``entries`` the full verdict
1674-
map. The SPA fires this on project load / switch and after a recalc.
1674+
map. Only current alias heads are ``stale`` (#154): a superseded historical
1675+
version reports ``live=False`` and is never listed — recomputing it re-points
1676+
no alias. The SPA fires this on project load / switch and after a recalc.
16751677
"""
16761678
project = _validate_project(project)
16771679
from tallyman_core.config import auto_recalc_enabled # noqa: PLC0415

src/tallyman_mcp/server.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1029,13 +1029,16 @@ def catalog_scan_staleness() -> dict:
10291029
a followed alias (built with ``tracked_expr_from_alias("name")``) advanced past the
10301030
recorded head, or a recorded source file's content drifted on disk. A
10311031
*directly* stale entry has its own input moved; a *transitively* stale entry
1032-
is a clean descendant of a stale ancestor. Use ``catalog_recalc`` to act.
1032+
is a clean descendant of a stale ancestor. Only current alias heads are
1033+
directly stale (#154): a superseded historical version reports ``live=False``
1034+
and is never listed, since recomputing it re-points no alias. Use
1035+
``catalog_recalc`` to act.
10331036
10341037
Returns:
10351038
stale: hashes that are directly stale (the natural recalc roots).
10361039
transitively_stale: clean descendants carried by a stale ancestor.
10371040
entries: ``{hash: verdict}`` for every live entry, where a verdict is
1038-
``{stale, reasons:[{axis, ref, was, now}], unknown_axes,
1041+
``{stale, live, reasons:[{axis, ref, was, now}], unknown_axes,
10391042
transitively_stale}``.
10401043
"""
10411044
project = _resolve_active_project()

src/tallyman_xorq/recalc.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,25 @@ def _preview_action(content_hash: str, verdict, cone: set[str], dag: dict) -> st
118118
return "unchanged" # pinned to an out-of-cone parent, or a non-stale root
119119

120120

121+
def _live_cone(cone: list[str], roots: list[str], verdicts: dict[str, StaleVerdict]) -> list[str]:
122+
"""The cone with dragged-in husks removed (#154). ``descendant_cone`` re-expands
123+
from a stale head through *every* recorded parent edge, so a superseded husk that
124+
follows that head (via a since-advanced alias) is pulled back in even though the
125+
head-gate dropped it from the root set. Replaying such a husk re-resolves its
126+
followed alias to the advanced head and manufactures a junk entry that heads no
127+
alias and re-points nothing — the f2dd/cb24 regression. Drop every non-head cone
128+
member; an explicitly-named root is exempt (a user-supplied root is a deliberate
129+
request, not scan-driven drag-in — the revise/recalc defaults never pass one).
130+
131+
Safe because a *live* entry can depend on a husk only through a ``follow=False``
132+
hash pin (nothing can follow a headless husk by name), and a hash pin replays to
133+
the same parent whether or not the husk itself was rebuilt — so nothing downstream
134+
is stranded by skipping it. Order is preserved, so the topological walk stays valid.
135+
"""
136+
roots_set = set(roots)
137+
return [h for h in cone if h in roots_set or verdicts[h].live]
138+
139+
121140
def recalc(project: str, roots: list[str], *, dry_run: bool = False) -> RecalcReport:
122141
"""Recompute *roots* and their dependents, in dependency order.
123142
@@ -146,6 +165,7 @@ def recalc(project: str, roots: list[str], *, dry_run: bool = False) -> RecalcRe
146165
# the project), then index into it for the cone. Keeps the staleness
147166
# semantics in one tested place rather than re-deriving them here.
148167
verdicts = scan(project)
168+
cone = _live_cone(cone, roots, verdicts) # drop dragged-in husks so preview matches the real walk (#154)
149169
cone_set = set(cone)
150170
dag = build_dag(project)
151171
entries = [
@@ -202,6 +222,7 @@ def _replay_cone(
202222

203223
if verdicts is None:
204224
verdicts = scan(project)
225+
cone = _live_cone(cone, roots, verdicts) # a husk dragged into a stale head's cone must not replay into junk (#154)
205226
remap: dict[str, str] = {}
206227
entries: list[RecalcEntry] = []
207228
status = "ok"

src/tallyman_xorq/staleness.py

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,11 @@ class StaleReason:
5252
@dataclass
5353
class StaleVerdict:
5454
content_hash: str
55-
stale: bool # directly stale: this entry's own recorded inputs moved
55+
stale: bool # actionably stale: a CURRENT alias head whose own recorded inputs moved
5656
reasons: list[StaleReason] = field(default_factory=list)
5757
unknown_axes: list[str] = field(default_factory=list) # axes that can't be evaluated
5858
transitively_stale: bool = False # set by scan(): a directly-stale ancestor exists
59+
live: bool = True # #154: content_hash is a current alias head (set by scan)
5960

6061

6162
@contextmanager
@@ -115,20 +116,35 @@ def entry_staleness(project: str, content_hash: str) -> StaleVerdict:
115116

116117

117118
def scan(project: str) -> dict[str, StaleVerdict]:
118-
"""Staleness for every live entry, tagging direct vs transitive staleness.
119-
120-
A **directly** stale entry has its own recorded input moved. A
121-
**transitively** stale entry is a (not directly stale) descendant of one in
122-
the dependency cone. The recalc surfaces use this to flag both while
123-
distinguishing the entry the user must act on from the ones a cascade carries.
119+
"""Actionable staleness for every live entry, tagging direct vs transitive.
120+
121+
An entry is **directly** stale only when it is a *current alias head* whose own
122+
recorded input moved (#154). A superseded historical version pins alias heads
123+
that have since advanced, so its inputs read as moved forever — but it heads no
124+
alias, so recomputing it re-points nothing (and, for a recipe shape no current
125+
head uses, manufactures a junk entry), and it is not an invariant break to flag.
126+
Such a non-head is marked ``live=False`` and forced ``stale=False`` here (its
127+
``reasons`` are kept for forensics). A **transitively** stale entry is a (not
128+
directly stale) descendant of a directly-stale head in the dependency cone.
129+
130+
``entry_staleness`` stays the raw per-entry primitive — did *this* entry's inputs
131+
move, regardless of headship; this function layers the head-reachability gate
132+
that makes ``stale`` mean *actionable*. The verdict dict still keys **every**
133+
entry (heads and husks alike), because the recalc replay indexes cone members
134+
that are not themselves heads (a hash-pinned child).
124135
"""
136+
heads = set(aliases.load_aliases(project).values()) # current alias heads, read once
125137
verdicts = {
126138
entry["content_hash"]: entry_staleness(project, entry["content_hash"]) for entry in list_entries(project)
127139
}
140+
for content_hash, verdict in verdicts.items():
141+
verdict.live = content_hash in heads
142+
if not verdict.live:
143+
verdict.stale = False # a non-head is never actionably stale (#154)
128144
directly_stale = [h for h, v in verdicts.items() if v.stale]
129145
if directly_stale:
130146
for content_hash in descendant_cone(project, directly_stale):
131147
verdict = verdicts.get(content_hash)
132-
if verdict is not None and not verdict.stale:
148+
if verdict is not None and verdict.live and not verdict.stale: # a husk is dead history, not carried (#154)
133149
verdict.transitively_stale = True
134150
return verdicts

tests/test_auto_recalc.py

Lines changed: 133 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,10 @@
2121
from tallyman_core.config import auto_recalc_enabled, set_auto_recalc
2222
from tallyman_core.errors import error_for_hash
2323
from tallyman_core.paths import entry_dir
24-
from tallyman_mcp.server import catalog_create, catalog_revise
24+
from tallyman_mcp.server import catalog_create, catalog_recalc, catalog_revise
2525
from tallyman_xorq.build import list_entries
26+
from tallyman_xorq.dependents import descendant_cone
27+
from tallyman_xorq.recalc import followers_of
2628
from tallyman_xorq.staleness import scan
2729

2830
# ---------------------------------------------------------------------------
@@ -473,3 +475,133 @@ def counting_scan(p):
473475
monkeypatch.setattr(recalc_mod, "scan", counting_scan)
474476
catalog_revise("a", _src_code_v2(project))
475477
assert calls["n"] == 1
478+
479+
480+
# ---------------------------------------------------------------------------
481+
# 10. head-reachability (#154): a SUPERSEDED historical version — a husk that no
482+
# alias still heads — is dead history. Its immutable pin to a since-advanced
483+
# alias makes it look stale forever, but recomputing it re-points nothing and,
484+
# for a recipe shape no current head uses, manufactures a junk entry (the
485+
# f2dd/cb24 regression). Staleness is gated on being a current alias head, so
486+
# a husk is never a recompute root (followers_of / scan-driven catalog_recalc)
487+
# and never a false UNEXPLAINED orphan.
488+
# ---------------------------------------------------------------------------
489+
490+
491+
def _tracked_mutate(parent: str, col: str, mult: int) -> str: # tracked follower, distinguishing column+graph
492+
return (
493+
"from tallyman_xorq.io import tracked_expr_from_alias\n"
494+
f"t = tracked_expr_from_alias({parent!r})\n"
495+
f"expr = t.mutate({col}=t.price * {mult})\n"
496+
)
497+
498+
499+
def _make_divergent_husk(project) -> tuple[str, str, str]:
500+
"""leaf@v1; alias ``u`` built on one recipe shape (husk_tag) then REBASED onto a
501+
different shape (live_tag). The original ``u`` version is now a superseded husk
502+
whose recipe shape no current head uses — replaying it against an advanced leaf
503+
yields a novel hash (the divergent-recipe junk). Returns (leaf_v1, husk, live_head)."""
504+
leaf1 = _hash(catalog_create("leaf", _src_code(project)))
505+
husk = _hash(catalog_create("u", _tracked_mutate("leaf", "husk_tag", 3)))
506+
live = catalog_revise("u", _tracked_mutate("leaf", "live_tag", 5))["hash"]
507+
assert live != husk and get_alias(project, "u") == live # u rebased; husk orphaned
508+
return leaf1, husk, live
509+
510+
511+
def test_revising_leaf_does_not_replay_a_superseded_husk(project, orders_parquet, monkeypatch):
512+
# THE f2dd/cb24 REGRESSION. Revising a leaf the husk pins must recompute only
513+
# u's live head, not replay the divergent husk into a brand-new junk entry.
514+
_clean_env(monkeypatch)
515+
_leaf1, husk, live = _make_divergent_husk(project)
516+
517+
before = {e["content_hash"] for e in list_entries(project)}
518+
out = catalog_revise("leaf", _src_code_v2(project)) # auto-recalc cascades from the head advance
519+
leaf2 = out["hash"]
520+
new_head = get_alias(project, "u")
521+
522+
assert new_head != live # u's live head recomputed against the advanced leaf
523+
# exactly two new entries — the leaf edit and u's live-head recompute — no junk husk replay
524+
new = {e["content_hash"] for e in list_entries(project)} - before
525+
assert new == {leaf2, new_head}
526+
assert out["recalc"]["remap"] == {live: new_head} # the husk is not a remap root
527+
assert husk not in out["recalc"]["remap"]
528+
529+
530+
def test_followers_of_excludes_superseded_husk(project, orders_parquet, monkeypatch):
531+
_clean_env(monkeypatch)
532+
set_auto_recalc(project, False) # advance leaf WITHOUT the cascade, to inspect the raw roots
533+
_leaf1, husk, live = _make_divergent_husk(project)
534+
catalog_revise("leaf", _src_code_v2(project)) # leaf head advances; nothing recomputed
535+
536+
follows = set(followers_of(project, "leaf"))
537+
assert live in follows # the live head is a genuine recompute root
538+
assert husk not in follows # the dead husk is not
539+
540+
541+
def test_scan_excludes_husk_from_stale_but_keeps_it_in_the_dict(project, orders_parquet, monkeypatch):
542+
_clean_env(monkeypatch)
543+
set_auto_recalc(project, False)
544+
_leaf1, husk, live = _make_divergent_husk(project)
545+
catalog_revise("leaf", _src_code_v2(project)) # leaf advances; no cascade
546+
547+
v = scan(project)
548+
assert v[live].stale is True # a current head whose input moved IS actionably stale
549+
assert v[husk].stale is False # the superseded husk is not
550+
assert husk in v # ...but still present in the dict (the replay loop indexes every cone member)
551+
552+
553+
def test_superseded_husk_is_not_an_unexplained_orphan(project, orders_parquet, monkeypatch):
554+
# The perpetual-false-alarm half: a husk must not fill orphan_stale with
555+
# "file a bug", while a genuinely-stale live head with no error still does.
556+
_clean_env(monkeypatch)
557+
set_auto_recalc(project, False)
558+
_second_source(project, "widgets.parquet", seed=1)
559+
_leaf1, husk, live = _make_divergent_husk(project)
560+
catalog_revise("leaf", _src_code_v2(project)) # leaf advances → husk + live both stale, none recomputed
561+
_hash(catalog_create("z", _src_code(project, "widgets.parquet"))) # unrelated alias
562+
set_auto_recalc(project, True)
563+
564+
out = catalog_revise("z", _src_code_v2(project, "widgets.parquet"))
565+
orphans = {o["hash"]: o for o in out["recalc"]["orphan_stale"]}
566+
assert live in orphans and "UNEXPLAINED" in orphans[live]["explanation"] # real stale head still flagged
567+
assert husk not in orphans # the husk is no longer a false UNEXPLAINED
568+
569+
570+
def test_scan_driven_recalc_does_not_replay_husk(project, orders_parquet, monkeypatch):
571+
# The SECOND junk-site: a no-arg catalog_recalc defaults its roots to every
572+
# directly-stale entry (server.py), bypassing followers_of. The head-gate must
573+
# cover it too, or the husk churns junk here instead of on the revise path.
574+
_clean_env(monkeypatch)
575+
set_auto_recalc(project, False)
576+
_leaf1, husk, live = _make_divergent_husk(project)
577+
catalog_revise("leaf", _src_code_v2(project)) # leaf advances; no cascade
578+
579+
before = {e["content_hash"] for e in list_entries(project)}
580+
catalog_recalc(dry_run=False) # roots default to the scan-driven directly-stale set
581+
new = {e["content_hash"] for e in list_entries(project)} - before
582+
assert new == {get_alias(project, "u")} # only u's live-head recompute; no husk junk
583+
584+
585+
def test_scan_driven_recalc_does_not_replay_husk_under_source_drift(project, orders_parquet, monkeypatch):
586+
# The SOURCE-DRIFT sibling of the test above. Instead of REVISING the leaf
587+
# (which advances its head, so the leaf drops out of the root set), drift the
588+
# leaf's SOURCE FILE on disk. The leaf HEAD stays a directly-stale root, so
589+
# descendant_cone([leaf]) re-expands through the husk's follow=True parent edge
590+
# and drags the husk back into the cone. The head-gate lives on the ROOT SET,
591+
# not on cone membership, so _replay_cone rebuilds the husk anyway — the
592+
# f2dd/cb24 junk-replay regression, on the source-drift path.
593+
_clean_env(monkeypatch)
594+
set_auto_recalc(project, False)
595+
leaf1, husk, _live = _make_divergent_husk(project)
596+
write_shoe_orders(data_dir(project) / "orders.parquet", n_rows=200, seed=1) # drift leaf's source in place
597+
598+
v = scan(project)
599+
assert v[leaf1].stale is True # leaf head directly stale on the source axis → a recalc root
600+
assert v[husk].stale is False and v[husk].live is False # husk gated out of the root set...
601+
assert husk in descendant_cone(project, [leaf1]) # ...but still dragged into the leaf's cone
602+
603+
before = {e["content_hash"] for e in list_entries(project)}
604+
catalog_recalc(dry_run=False) # roots default to the scan-driven directly-stale set = [leaf]
605+
new = {e["content_hash"] for e in list_entries(project)} - before
606+
# exactly two new entries — the leaf source-recompute and u's live-head recompute — no junk husk replay
607+
assert new == {get_alias(project, "leaf"), get_alias(project, "u")}

tests/test_recalc.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,11 @@ def test_alias_advance_cascades_to_the_follower(project, orders_parquet, monkeyp
134134
# follower b *directly* stale on the alias axis; recalc re-resolves b to the
135135
# advanced head. b's source is unchanged, so this isolates the alias cascade.
136136
monkeypatch.setenv("TALLYMAN_SOURCE_IDENTITY", "cas")
137+
# Opt out of auto-recalc-on-revise so the revise advances a WITHOUT recomputing
138+
# b: otherwise the cascade re-points "b" to a fresh head and b1 becomes a
139+
# superseded husk (never actionably stale under #154), not the *live* directly-
140+
# stale follower this test means to observe and then recalc explicitly.
141+
monkeypatch.setenv("TALLYMAN_AUTO_RECALC", "0")
137142
a1 = _hash(catalog_create("a", _base_code(project)))
138143
b1 = _hash(catalog_create("b", _child_code("a")))
139144

0 commit comments

Comments
 (0)