Skip to content

Commit 618f2b5

Browse files
paddymulclaude
andcommitted
fix(reactive): close the three doc-vs-code holes the pins exposed
Each fix is code-side, landing the behavior the docs already promise. 1. Explicit husk root is dropped from the cone (#154 extension). `_live_cone` exempted explicitly-named roots from the husk gate, so `recalc(project, [husk])` replayed a superseded version and, under source drift, minted a junk sibling of the live head. Drop the exemption: a husk (`live=False`) is dropped whether dragged in by the cone expansion or named explicitly, per mcp-server.md ("roots naming no live entry are silently dropped"). The scan's own `live=False` verdict is the authority. 2. Scratch followers rebuild again. `scan` gated `live` on "is a current alias head", which also excluded never-aliased `catalog_run` scratch followers, so an advanced parent never made them actionably stale. Redefine the gate as "is a superseded husk" (appears in some alias history but is no longer its head); a scratch entry appears in no history, so it stays live and rebuilds like any follower — minus the alias bookkeeping — matching reactive-recalc.md's scratch semantics. 3. Alias pins survive a replay. `pinned_expr_from_alias("p")` stores the alias name, so a replay forced by a *tracked* parent advancing re-resolved the name to p's new head and silently advanced the pin. Freeze alias-pinned parents to the revision recorded in `manifest.parents` for the duration of the replay (new `io.pin_freeze` context, set by `_replay_cone` from `_pinned_alias_freeze`), so the pin stays on its exact revision as the docs promise. Hash pins were already durable and are untouched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 87cb3be commit 618f2b5

3 files changed

Lines changed: 110 additions & 36 deletions

File tree

src/tallyman_xorq/io.py

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99

1010
from __future__ import annotations
1111

12+
import contextlib
13+
import contextvars
1214
from pathlib import Path
1315

1416
from tallyman_core import data_dir, entry_dir, get_alias, resolve_project
@@ -18,6 +20,30 @@ class ProjectDataNotFound(FileNotFoundError):
1820
pass
1921

2022

23+
# A replay-time freeze for alias-pinned parents (#160). ``pinned_expr_from_alias("p")``
24+
# stores the alias *name* in the recipe, so a naive replay re-resolves the name to p's
25+
# CURRENT head and the pin silently advances — the docs promise a pin "stays on that
26+
# exact revision" and is "never disturbed by recalc". During a recalc replay the walk
27+
# knows the revision the pin resolved to at build (``manifest.parents[i].hash``) and
28+
# sets this map ``{alias_name: frozen_hash}`` so ``pinned_expr_from_alias`` re-resolves
29+
# to the recorded revision instead of the live head. A pin by *hash* stores the hash
30+
# literally and never consults this map — it is already replay-durable.
31+
_PIN_FREEZE: contextvars.ContextVar[dict[str, str] | None] = contextvars.ContextVar("tallyman_pin_freeze", default=None)
32+
33+
34+
@contextlib.contextmanager
35+
def pin_freeze(mapping: dict[str, str]):
36+
"""Freeze alias-pinned parents to their recorded revisions for the duration of a
37+
recalc replay (#160). ``mapping`` is ``{alias_name: content_hash}``; while it is
38+
active, ``pinned_expr_from_alias(name)`` resolves ``name`` to ``content_hash``
39+
rather than the alias's current head."""
40+
token = _PIN_FREEZE.set(mapping)
41+
try:
42+
yield
43+
finally:
44+
_PIN_FREEZE.reset(token)
45+
46+
2147
def project_path(rel_path: str, project: str | None = None, must_exist: bool = True) -> Path:
2248
"""Resolve `rel_path` against the project's data dir. Raises if absent.
2349
@@ -99,10 +125,19 @@ def read_project_file(rel_path: str, project: str | None = None):
99125
# Covers the types the MCP documents for tallyman_read_csv; an unmapped type
100126
# raises rather than silently inferring, so a schema mistake fails loudly.
101127
_IBIS_TO_POLARS = {
102-
"int8": "Int8", "int16": "Int16", "int32": "Int32", "int64": "Int64",
103-
"uint8": "UInt8", "uint16": "UInt16", "uint32": "UInt32", "uint64": "UInt64",
104-
"float32": "Float32", "float64": "Float64",
105-
"string": "String", "bool": "Boolean", "boolean": "Boolean",
128+
"int8": "Int8",
129+
"int16": "Int16",
130+
"int32": "Int32",
131+
"int64": "Int64",
132+
"uint8": "UInt8",
133+
"uint16": "UInt16",
134+
"uint32": "UInt32",
135+
"uint64": "UInt64",
136+
"float32": "Float32",
137+
"float64": "Float64",
138+
"string": "String",
139+
"bool": "Boolean",
140+
"boolean": "Boolean",
106141
"date": "Date",
107142
}
108143

@@ -273,7 +308,7 @@ def _normalize_schema(spec, header: list[str], *, reserved: tuple[str, ...] = ()
273308
f"{len(speccable)} {list(speccable)}.{reserved_hint}"
274309
)
275310
if rest_dtype is None and len(cells) != len(speccable):
276-
uncovered = list(speccable[len(cells):])
311+
uncovered = list(speccable[len(cells) :])
277312
raise ValueError(
278313
f"tallyman_read_csv: schema is not total — it leaves column(s) {uncovered} unspecified. "
279314
'Name every column, or close the spec with ("&rest", "infer").'
@@ -289,7 +324,7 @@ def _normalize_schema(spec, header: list[str], *, reserved: tuple[str, ...] = ()
289324
inferred = True
290325
else:
291326
overrides[orig] = pl_dt
292-
for orig in speccable[len(cells):]: # the &rest tail keeps its header name
327+
for orig in speccable[len(cells) :]: # the &rest tail keeps its header name
293328
out_names.append(orig)
294329
pl_dt = _resolve_spec_dtype(rest_dtype)
295330
if pl_dt is None:
@@ -717,7 +752,16 @@ def pinned_expr_from_alias(alias_or_hash: str, project: str | None = None):
717752

718753
proj = resolve_project(project)
719754
is_hash = entry_dir(proj, alias_or_hash).exists()
720-
content_hash = alias_or_hash if is_hash else get_alias(proj, alias_or_hash)
755+
if is_hash:
756+
content_hash = alias_or_hash # a literal hash pin is already replay-durable
757+
else:
758+
# An alias-name pin: during a recalc replay, resolve to the revision recorded
759+
# at build (the freeze map) so the pin is not silently advanced by the replay
760+
# (#160); otherwise resolve the alias's current head as usual.
761+
frozen = _PIN_FREEZE.get()
762+
content_hash = frozen.get(alias_or_hash) if frozen else None
763+
if content_hash is None:
764+
content_hash = get_alias(proj, alias_or_hash)
721765
if content_hash is None or not entry_dir(proj, content_hash).exists():
722766
raise ProjectDataNotFound(f"catalog entry {alias_or_hash!r} not found in project {proj!r}")
723767
content_hash = _resolve_noncyclic_hash(proj, alias_or_hash, content_hash)

src/tallyman_xorq/recalc.py

Lines changed: 35 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,8 @@
4646
from tallyman_core.errors import errors_by_hash, record_error
4747
from tallyman_core.paths import entry_dir, entry_manifest_path, project_dir
4848
from tallyman_xorq.build import BuildError, build_and_persist
49-
from tallyman_xorq.dependents import DependencyCycleError, build_dag, descendant_cone
49+
from tallyman_xorq.dependents import DependencyCycleError, build_dag, descendant_cone, parents_of
50+
from tallyman_xorq.io import pin_freeze
5051
from tallyman_xorq.portable import PLACEHOLDER
5152
from tallyman_xorq.staleness import StaleReason, StaleVerdict, scan
5253

@@ -102,6 +103,18 @@ def _recipe_code(project: str, content_hash: str) -> str:
102103
return code.replace(PLACEHOLDER, str(project_dir(project)))
103104

104105

106+
def _pinned_alias_freeze(project: str, content_hash: str) -> dict[str, str]:
107+
"""``{alias_name: recorded_hash}`` for this entry's alias-pinned parents (#160).
108+
109+
A ``follow=False`` parent whose ``ref`` is an alias *name* (not the pinned hash
110+
itself) was resolved to ``ref``'s head at build; on replay the recipe re-runs
111+
``pinned_expr_from_alias(ref)`` and would re-resolve the name to the alias's *new*
112+
head, silently advancing the pin. Freezing ``ref -> hash`` keeps the pin on its
113+
recorded revision. A hash pin (``ref == hash``) stores the hash literally in the
114+
recipe and is already replay-durable, so it needs no freeze entry."""
115+
return {p.ref: p.hash for p in parents_of(project, content_hash) if not p.follow and p.ref != p.hash}
116+
117+
105118
def _aliases_heading(project: str, content_hash: str) -> list[str]:
106119
"""Every alias whose *current* head is ``content_hash`` (usually one, never
107120
more for a freshly-built entry; a doubly-aliased head returns both)."""
@@ -118,23 +131,25 @@ def _preview_action(content_hash: str, verdict, cone: set[str], dag: dict) -> st
118131
return "unchanged" # pinned to an out-of-cone parent, or a non-stale root
119132

120133

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).
134+
def _live_cone(cone: list[str], verdicts: dict[str, StaleVerdict]) -> list[str]:
135+
"""The cone with husks removed (#154). ``descendant_cone`` re-expands from a stale
136+
head through *every* recorded parent edge, so a superseded husk that follows that
137+
head (via a since-advanced alias) is pulled back in. Replaying such a husk
138+
re-resolves its followed alias to the advanced head and manufactures a junk entry
139+
that heads no alias and re-points nothing — the f2dd/cb24 regression.
140+
141+
Drop every husk (``live=False``), whether it was dragged in by the cone expansion
142+
OR named explicitly as a root: mcp-server.md promises "roots naming no live entry
143+
are silently dropped", and the scan's own ``live=False`` verdict for the husk is
144+
the authority on that. An explicit husk root replayed under source drift mints the
145+
same junk sibling as the scan-driven path, so the gate must cover both.
130146
131147
Safe because a *live* entry can depend on a husk only through a ``follow=False``
132148
hash pin (nothing can follow a headless husk by name), and a hash pin replays to
133149
the same parent whether or not the husk itself was rebuilt — so nothing downstream
134150
is stranded by skipping it. Order is preserved, so the topological walk stays valid.
135151
"""
136-
roots_set = set(roots)
137-
return [h for h in cone if h in roots_set or verdicts[h].live]
152+
return [h for h in cone if verdicts[h].live]
138153

139154

140155
def recalc(project: str, roots: list[str], *, dry_run: bool = False) -> RecalcReport:
@@ -165,7 +180,7 @@ def recalc(project: str, roots: list[str], *, dry_run: bool = False) -> RecalcRe
165180
# the project), then index into it for the cone. Keeps the staleness
166181
# semantics in one tested place rather than re-deriving them here.
167182
verdicts = scan(project)
168-
cone = _live_cone(cone, roots, verdicts) # drop dragged-in husks so preview matches the real walk (#154)
183+
cone = _live_cone(cone, verdicts) # drop husks so preview matches the real walk (#154)
169184
cone_set = set(cone)
170185
dag = build_dag(project)
171186
entries = [
@@ -222,7 +237,7 @@ def _replay_cone(
222237

223238
if verdicts is None:
224239
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)
240+
cone = _live_cone(cone, verdicts) # a husk (dragged-in or explicitly named) must not replay into junk (#154)
226241
remap: dict[str, str] = {}
227242
entries: list[RecalcEntry] = []
228243
status = "ok"
@@ -246,8 +261,13 @@ def _replay_cone(
246261
# Resolve the heading alias(es) BEFORE re-pointing anything for this entry,
247262
# so a yet-to-be-processed downstream alias is read at its pre-recalc head.
248263
heading = _aliases_heading(project, old_hash)
264+
# Freeze alias-pinned parents to the revision they resolved to at build, so a
265+
# replay forced by a *tracked* parent advancing does not silently re-resolve
266+
# `pinned_expr_from_alias("p")` to p's new head (#160).
267+
freeze = _pinned_alias_freeze(project, old_hash)
249268
try:
250-
result = build_and_persist(project, _recipe_code(project, old_hash))
269+
with pin_freeze(freeze):
270+
result = build_and_persist(project, _recipe_code(project, old_hash))
251271
except BuildError as exc:
252272
# Persist the failure (outside the catalog repo, so it survives
253273
# reset_to) keyed by the failed hash — the orphan-stale tie-back.

src/tallyman_xorq/staleness.py

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -118,29 +118,39 @@ def entry_staleness(project: str, content_hash: str) -> StaleVerdict:
118118
def scan(project: str) -> dict[str, StaleVerdict]:
119119
"""Actionable staleness for every live entry, tagging direct vs transitive.
120120
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.
121+
An entry is **live** unless it is a *superseded husk* — a version that appears
122+
in some alias's history but is no longer that alias's head (#154). A husk pins
123+
alias heads that have since advanced, so its inputs read as moved forever, yet
124+
recomputing it re-points nothing (and, for a recipe shape no current head uses,
125+
manufactures a junk entry). Such a husk is marked ``live=False`` and forced
126+
``stale=False`` here (its ``reasons`` are kept for forensics).
127+
128+
A never-aliased *scratch* entry (a ``catalog_run`` follower that appears in no
129+
alias history) is **not** a husk: nothing has superseded it, so it stays live
130+
and goes actionably stale when a parent it follows advances — it rebuilds like
131+
any follower, minus the alias bookkeeping (reactive-recalc.md's scratch-rebuild
132+
semantics). The gate is "superseded", not "is a head".
133+
134+
A **transitively** stale entry is a (not directly stale) descendant of a
135+
directly-stale head in the dependency cone.
129136
130137
``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).
138+
move, regardless of headship; this function layers the husk gate that makes
139+
``stale`` mean *actionable*. The verdict dict still keys **every** entry (live
140+
entries and husks alike), because the recalc replay indexes cone members that
141+
are not themselves heads (a hash-pinned child).
135142
"""
136143
heads = set(aliases.load_aliases(project).values()) # current alias heads, read once
144+
# A husk is a hash that appears in some alias history but is no longer a head;
145+
# a never-aliased scratch entry appears in no history, so it is not a husk.
146+
superseded = {h for hist in aliases.load_history(project).values() for h in hist} - heads
137147
verdicts = {
138148
entry["content_hash"]: entry_staleness(project, entry["content_hash"]) for entry in list_entries(project)
139149
}
140150
for content_hash, verdict in verdicts.items():
141-
verdict.live = content_hash in heads
151+
verdict.live = content_hash not in superseded
142152
if not verdict.live:
143-
verdict.stale = False # a non-head is never actionably stale (#154)
153+
verdict.stale = False # a superseded husk is never actionably stale (#154)
144154
directly_stale = [h for h, v in verdicts.items() if v.stale]
145155
if directly_stale:
146156
for content_hash in descendant_cone(project, directly_stale):

0 commit comments

Comments
 (0)