From cd21c8ad3b91f8c8d94ec7df7b180c89ccc46b51 Mon Sep 17 00:00:00 2001 From: Paddy Mullen Date: Wed, 1 Jul 2026 16:23:52 -0400 Subject: [PATCH 1/3] test: pin doc-promised reactive and caching edges Twenty new tests written from docs/reactive-recalc.md, caching.md, architecture.md, expression-lifecycle.md, and mcp-server.md, covering edges the suite did not exercise: - staleness: alias-form pins, deleted alias/source inputs (unknown_axes), alias recreated at a new head, both axes stale at once, cheap-parent source inlining vs expensive-parent shielding (incl. the sources-union invariant), and an in-place edit that preserves mtime and byte length (the faithful re-read claim) - recalc: diamond replay (join child rebuilds once against both advanced heads), stop-and-report through a diamond, explicit roots on transitively-stale or fresh entries, roots=[] as an explicit empty selection, two aliases on one head advancing together, replay after a followed alias was deleted - auto-recalc: a byte-identical revise dedupes (same hash, same version, empty cascade); cascade carry-forward of chart/display config to rebuilt followers (works, but undocumented) - caching: salt mode bakes no snapshot and reads recompute; salt folds source digests into the hash; tracked_expr_from_alias rejects a hash Co-Authored-By: Claude Fable 5 --- tests/test_auto_recalc.py | 29 +++- tests/test_io.py | 15 ++ tests/test_recalc.py | 270 +++++++++++++++++++++++++++++++- tests/test_revise_carry_over.py | 37 +++++ tests/test_source_identity.py | 49 ++++++ tests/test_staleness.py | 182 ++++++++++++++++++++- 6 files changed, 577 insertions(+), 5 deletions(-) diff --git a/tests/test_auto_recalc.py b/tests/test_auto_recalc.py index e9ebfa6..438ddbe 100644 --- a/tests/test_auto_recalc.py +++ b/tests/test_auto_recalc.py @@ -16,7 +16,7 @@ from tallyman_cli.fixtures import write_shoe_orders from tallyman_core import data_dir -from tallyman_core.aliases import get_alias +from tallyman_core.aliases import get_alias, history_for from tallyman_core.catalog_state import current_step, reset_to from tallyman_core.config import auto_recalc_enabled, set_auto_recalc from tallyman_core.errors import error_for_hash @@ -605,3 +605,30 @@ def test_scan_driven_recalc_does_not_replay_husk_under_source_drift(project, ord new = {e["content_hash"] for e in list_entries(project)} - before # exactly two new entries — the leaf source-recompute and u's live-head recompute — no junk husk replay assert new == {get_alias(project, "leaf"), get_alias(project, "u")} + + +# --------------------------------------------------------------------------- +# 9. a byte-identical revise is a no-op head advance: no new revision, no cascade +# --------------------------------------------------------------------------- + + +def test_revise_identical_code_keeps_version_and_cascades_nothing(project, orders_parquet, monkeypatch): + """docs/reactive-recalc.md: "a new alias revision is created exactly when an + alias head advances to a hash it was not already pointing at" and + "set_alias dedupes a consecutive duplicate hash regardless". Re-submitting + the identical recipe rebuilds to the same content hash, so the alias history + must not grow, followers must not go stale, and the cascade must be empty.""" + _clean_env(monkeypatch) + a1 = _hash(catalog_create("a", _src_code(project))) + b1 = _hash(catalog_create("b", _child_code("a"))) + + out = catalog_revise("a", _src_code(project)) # byte-identical recipe + + assert "error" not in out, out + assert out["hash"] == a1 # build idempotency: same structure, same hash + assert get_alias(project, "a") == a1 + assert history_for(project, "a") == [a1] # deduped — still version 1 + assert out.get("version") == 1 + assert get_alias(project, "b") == b1 # nothing went stale, nothing cascaded + assert (out.get("recalc") or {}).get("remap") in (None, {}) + assert scan(project)[b1].stale is False diff --git a/tests/test_io.py b/tests/test_io.py index e928734..713c23c 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -32,3 +32,18 @@ def test_read_project_file_returns_xorq_expr(orders_parquet: Path, project: str, schema = expr.schema() assert "region" in schema.names assert "price" in schema.names + + +def test_tracked_expr_from_alias_rejects_a_hash(orders_parquet: Path, project: str, monkeypatch): + """docs/reactive-recalc.md: ``tracked_expr_from_alias`` accepts an alias only + — "pass it a hash and it raises, because a hash has no head to follow". The + hash form must not silently degrade into an untracked/pinned read.""" + from tallyman_xorq import build_and_persist + from tallyman_xorq.io import tracked_expr_from_alias + + monkeypatch.setenv("TALLYMAN_PROJECT", project) + code = 'from tallyman_xorq.io import read_project_file\nexpr = read_project_file("orders.parquet")\n' + content_hash = build_and_persist(project, code).content_hash + + with pytest.raises(Exception): + tracked_expr_from_alias(content_hash, project=project) diff --git a/tests/test_recalc.py b/tests/test_recalc.py index adbf66c..f4fc087 100644 --- a/tests/test_recalc.py +++ b/tests/test_recalc.py @@ -19,14 +19,22 @@ from tallyman_cli.fixtures import write_shoe_orders from tallyman_core import data_dir -from tallyman_core.aliases import get_alias +from tallyman_core.aliases import alias_for_hash, get_alias, history_for from tallyman_core.catalog_state import current_step, reset_to from tallyman_core.manifest import Manifest, ParentRef, write_manifest from tallyman_core.paths import entry_dir -from tallyman_mcp.server import catalog_create, catalog_revise +from tallyman_mcp.server import ( + catalog_alias, + catalog_create, + catalog_recalc, + catalog_revise, + catalog_run, + catalog_unalias, +) from tallyman_xorq.build import list_entries +from tallyman_xorq.dependents import parents_of from tallyman_xorq.recalc import recalc -from tallyman_xorq.staleness import scan +from tallyman_xorq.staleness import entry_staleness, scan def _base_code(project: str) -> str: # root over orders.parquet @@ -61,6 +69,80 @@ def _pinned_child_code(content_hash: str) -> str: # pinned child off a specific """ +def _agg_child_code(parent: str) -> str: # expensive (Aggregate) child → bakes a snapshot + return f""" +from tallyman_xorq.io import tracked_expr_from_alias +t = tracked_expr_from_alias({parent!r}) +expr = t.group_by("region").aggregate(total=t.price.sum()) +""" + + +def _const_child_code(parent: str) -> str: # cheap child, schema-independent + return f""" +from tallyman_xorq.io import tracked_expr_from_alias +t = tracked_expr_from_alias({parent!r}) +expr = t.mutate(flag=1) +""" + + +def _wide_code(project: str) -> str: # root that keeps the join key + return f""" +from tallyman_xorq.io import read_project_file +t = read_project_file("orders.parquet", project={project!r}) +expr = t.select("order_id", "region", "price") +""" + + +def _wide_code_v2(project: str) -> str: # different graph, same columns + return f""" +from tallyman_xorq.io import read_project_file +t = read_project_file("orders.parquet", project={project!r}) +expr = t.filter(t.qty >= 1).select("order_id", "region", "price") +""" + + +def _price_arm_code(parent: str) -> str: # diamond arm: order_id + price + return f""" +from tallyman_xorq.io import tracked_expr_from_alias +t = tracked_expr_from_alias({parent!r}) +expr = t.select("order_id", "price") +""" + + +def _region_arm_code(parent: str) -> str: # diamond arm: order_id + region + return f""" +from tallyman_xorq.io import tracked_expr_from_alias +t = tracked_expr_from_alias({parent!r}) +expr = t.select("order_id", "region") +""" + + +def _join_code(left: str, right: str) -> str: # diamond bottom: join the two arms + return f""" +from tallyman_xorq.io import tracked_expr_from_alias +l = tracked_expr_from_alias({left!r}) +r = tracked_expr_from_alias({right!r}) +expr = l.join(r, "order_id") +""" + + +def _filtered_code(project: str, threshold: int) -> str: # same schema as _base_code + return f""" +from tallyman_xorq.io import read_project_file +t = read_project_file("orders.parquet", project={project!r}) +expr = t.filter(t.price > {threshold}).select("region", "price") +""" + + +def _union_tracked_pinned_code(tracked: str, pinned: str) -> str: # one tracked + one pinned parent + return f""" +from tallyman_xorq.io import tracked_expr_from_alias, pinned_expr_from_alias +t = tracked_expr_from_alias({tracked!r}) +p = pinned_expr_from_alias({pinned!r}) +expr = t.union(p) +""" + + def _hash(result: dict) -> str: assert "hash" in result, result return result["hash"] @@ -278,3 +360,185 @@ def test_recalc_skips_a_root_dir_without_a_manifest(project): assert report.cone == [] assert report.entries == [] assert report.remap == {} + + +# --------------------------------------------------------------------------- +# diamond: the join child rebuilds once, after both arms, against both new heads +# --------------------------------------------------------------------------- + + +def test_diamond_join_child_rebuilds_once_against_both_new_parents(project, orders_parquet, monkeypatch): + monkeypatch.setenv("TALLYMAN_SOURCE_IDENTITY", "cas") + monkeypatch.setenv("TALLYMAN_AUTO_RECALC", "0") + catalog_create("a", _wide_code(project)) + b1 = _hash(catalog_create("b", _price_arm_code("a"))) + c1 = _hash(catalog_create("c", _region_arm_code("a"))) + d1 = _hash(catalog_create("d", _join_code("b", "c"))) + + _hash(catalog_revise("a", _wide_code_v2(project))) + verdicts = scan(project) + assert verdicts[b1].stale is True and verdicts[c1].stale is True + + report = recalc(project, [b1, c1], dry_run=False) + + assert report.status == "ok" + b2, c2, d2 = get_alias(project, "b"), get_alias(project, "c"), get_alias(project, "d") + assert report.remap == {b1: b2, c1: c2, d1: d2} + # d is walked exactly once, after BOTH of its parents. + order = [e.content_hash for e in report.entries] + assert order.count(d1) == 1 + assert order.index(d1) > order.index(b1) and order.index(d1) > order.index(c1) + # ... and its rebuild bound to both advanced heads, not a mixed old/new pair. + parent_hashes = {p["hash"] for p in next(e for e in list_entries(project) if e["content_hash"] == d2)["parents"]} + assert parent_hashes == {b2, c2} + assert report.checkpoint_step is not None + assert all(v.stale is False for v in scan(project).values()) + + +def test_diamond_failure_skips_the_join_child(project, orders_parquet, monkeypatch): + """Stop-and-report in a diamond: when one arm fails, the walk stops there — + the join child below it must be ``skipped``, never rebuilt against a half- + advanced parent pair, and everything ordered after the failure is skipped + too (even an independent sibling arm).""" + monkeypatch.setenv("TALLYMAN_SOURCE_IDENTITY", "cas") + monkeypatch.setenv("TALLYMAN_AUTO_RECALC", "0") + catalog_create("a", _wide_code(project)) + b1 = _hash(catalog_create("b", _price_arm_code("a"))) + c1 = _hash(catalog_create("c", _region_arm_code("a"))) + d1 = _hash(catalog_create("d", _join_code("b", "c"))) + + (entry_dir(project, b1) / "expr.py").write_text("this is not valid python (((\n") + _hash(catalog_revise("a", _wide_code_v2(project))) + + report = recalc(project, [b1, c1], dry_run=False) + + assert report.status == "failed" + by_hash = {e.content_hash: e for e in report.entries} + assert by_hash[b1].action == "failed" and by_hash[b1].error + assert by_hash[d1].action == "skipped" + assert get_alias(project, "b") == b1 and get_alias(project, "d") == d1 + order = [e.content_hash for e in report.entries] + i = order.index(b1) + assert all(by_hash[h].action in {"rebuilt", "noop"} for h in order[:i]) + assert all(by_hash[h].action == "skipped" for h in order[i + 1 :]) + + +# --------------------------------------------------------------------------- +# root selection edges: transitive-only roots, non-stale roots, [], husks +# --------------------------------------------------------------------------- + + +def test_explicit_root_on_transitively_stale_child_noops_and_leaves_parent_stale( + project, orders_parquet, monkeypatch +): + """Rooting a recalc at a merely-transitively-stale entry recomputes nothing: + the replay reads its followed parent's UNMOVED head and short-circuits to a + noop. The actual staleness (the parent) is untouched — the cone only ever + expands downstream from the roots, so healing requires rooting at the + directly-stale set.""" + monkeypatch.setenv("TALLYMAN_SOURCE_IDENTITY", "cas") + monkeypatch.setenv("TALLYMAN_AUTO_RECALC", "0") + catalog_create("a", _base_code(project)) + # b is expensive so its source edge doesn't inline into c — c stays purely transitive. + b1 = _hash(catalog_create("b", _agg_child_code("a"))) + c1 = _hash(catalog_create("c", _const_child_code("b"))) + _hash(catalog_revise("a", _base_code_v2(project))) + + verdicts = scan(project) + assert verdicts[b1].stale is True + assert verdicts[c1].stale is False and verdicts[c1].transitively_stale is True + + step_before = current_step(project) + report = recalc(project, [c1], dry_run=False) + + assert report.status == "ok" + assert report.remap == {} + assert report.checkpoint_step is None + assert current_step(project) == step_before + assert get_alias(project, "c") == c1 + assert scan(project)[b1].stale is True # the real staleness is untouched + + +def test_explicit_non_stale_root_previews_as_unchanged(project, orders_parquet, monkeypatch): + """docs/reactive-recalc.md dry-run vocabulary: ``unchanged`` covers "a root + that isn't stale". A fresh entry passed explicitly must preview as that, + not as a rebuild.""" + monkeypatch.setenv("TALLYMAN_SOURCE_IDENTITY", "cas") + a1 = _hash(catalog_create("a", _base_code(project))) + _hash(catalog_create("b", _child_code("a"))) + + report = recalc(project, [a1], dry_run=True) + + assert report.status == "ok" + actions = {e.content_hash: e.action for e in report.entries} + assert actions[a1] == "unchanged" + + +def test_empty_roots_list_recomputes_nothing(project, orders_parquet, monkeypatch): + """``roots=None`` means "default to the stale set"; ``roots=[]`` is an + explicit empty selection and must NOT fall back to the stale set (the + classic falsy-coercion bug). With staleness present, an empty selection + rebuilds nothing and leaves the stale entry stale.""" + monkeypatch.setenv("TALLYMAN_SOURCE_IDENTITY", "cas") + a1 = _hash(catalog_create("a", _base_code(project))) + _edit_source(project) + assert scan(project)[a1].stale is True + step_before = current_step(project) + + out = catalog_recalc(roots=[], dry_run=False) + + assert "error" not in out, out + assert out["status"] == "ok" + assert out.get("remap") in (None, {}) + assert get_alias(project, "a") == a1 + assert current_step(project) == step_before + assert scan(project)[a1].stale is True + + +# --------------------------------------------------------------------------- +# alias bookkeeping through the walk: multi-alias heads, scratch entries, pins +# --------------------------------------------------------------------------- + + +def test_rebuilt_head_with_two_aliases_advances_both(project, orders_parquet, monkeypatch): + """docs/reactive-recalc.md: the revision is appended per advanced alias + head — "A head carrying two aliases advances both." """ + monkeypatch.setenv("TALLYMAN_SOURCE_IDENTITY", "cas") + monkeypatch.setenv("TALLYMAN_AUTO_RECALC", "0") + catalog_create("a", _base_code(project)) + h1 = _hash(catalog_create("b", _child_code("a"))) + out = catalog_alias(h1, "bb") # a second name on the same head + assert "error" not in out, out + assert get_alias(project, "bb") == h1 + + catalog_revise("a", _base_code_v2(project)) + report = recalc(project, [h1], dry_run=False) + + assert report.status == "ok" + h2 = report.remap[h1] + assert get_alias(project, "b") == h2 + assert get_alias(project, "bb") == h2 + assert history_for(project, "b") == [h1, h2] + assert history_for(project, "bb") == [h1, h2] + + +def test_replay_with_deleted_parent_alias_fails_cleanly(project, orders_parquet, monkeypatch): + """A deleted followed alias is an unknown axis (not stale), so a scan never + roots there — but an explicit recalc still replays the child, whose + ``tracked_expr_from_alias`` now has nothing to resolve. That must surface as + a structured per-entry failure, not a crash, with the alias map untouched.""" + monkeypatch.setenv("TALLYMAN_SOURCE_IDENTITY", "cas") + catalog_create("a", _base_code(project)) + b1 = _hash(catalog_create("b", _child_code("a"))) + out = catalog_unalias("a") + assert "error" not in out, out + + v = entry_staleness(project, b1) + assert v.stale is False and any(u.startswith("alias") for u in v.unknown_axes) + + report = recalc(project, [b1], dry_run=False) + + assert report.status == "failed" + by_hash = {e.content_hash: e for e in report.entries} + assert by_hash[b1].action == "failed" and by_hash[b1].error + assert get_alias(project, "b") == b1 diff --git a/tests/test_revise_carry_over.py b/tests/test_revise_carry_over.py index d7455d5..dfaa63d 100644 --- a/tests/test_revise_carry_over.py +++ b/tests/test_revise_carry_over.py @@ -19,6 +19,14 @@ def _current_hash(project: str) -> str: return list_entries(project)[0]["content_hash"] +def _kid_code(parent: str) -> str: # tracked follower — rebuilt by the cascade + return f""" +from tallyman_xorq.io import tracked_expr_from_alias +t = tracked_expr_from_alias({parent!r}) +expr = t.mutate(doubled=t.price * 2) +""" + + def test_revise_carries_chart_and_display_config(project, orders_parquet, monkeypatch): """A revise must seed the new version's per-entry config (chart + display config) from the previous version. Both are keyed by content hash, so the @@ -39,3 +47,32 @@ def test_revise_carries_chart_and_display_config(project, orders_parquet, monkey assert get_chart(project, child) == chart, "chart did not carry over to the new version" assert get_display_config(project, child) == display, "display config did not carry over" + + +def test_cascade_carries_chart_to_rebuilt_follower(project, orders_parquet, monkeypatch): + """Per-hash config is keyed by content hash, and an auto-recalc cascade mints + NEW hashes for rebuilt followers — exactly the orphaning problem + ``carry_forward_entry_config`` exists to solve for ``catalog_revise``. A + follower's chart and display config must survive an upstream revise's + cascade the same way, or every upstream edit silently strips the charts off + everything downstream.""" + monkeypatch.setenv("TALLYMAN_PROJECT", project) + monkeypatch.setenv("TALLYMAN_SOURCE_IDENTITY", "cas") + monkeypatch.delenv("TALLYMAN_AUTO_RECALC", raising=False) # default ON + catalog_create("base", _select(project, '"order_id", "region", "price"')) + catalog_create("kid", _kid_code("base")) + kid1 = _current_hash(project) + + chart = {"mark": "bar", "encoding": {"x": {"field": "region"}, "y": {"field": "price"}}} + display = {"column_config_overrides": {"price": {"color_map_config": "BLUE_TO_RED"}}} + set_chart(project, kid1, chart) + set_display_config(project, kid1, display) + + out = catalog_revise("base", _select(project, '"price", "region", "order_id"')) # reorder + kid2 = ((out.get("recalc") or {}).get("remap") or {}).get(kid1) + assert kid2, f"cascade did not rebuild the follower: {out.get('recalc')}" + + assert get_chart(project, kid2) == chart, "chart did not carry over through the cascade" + assert get_display_config(project, kid2) == display, ( + "display config did not carry over through the cascade" + ) diff --git a/tests/test_source_identity.py b/tests/test_source_identity.py index ce746dc..09680c4 100644 --- a/tests/test_source_identity.py +++ b/tests/test_source_identity.py @@ -94,3 +94,52 @@ def test_cas_clone_is_a_snapshot_not_a_hardlink(project): assert pd.read_parquet(clone).shape[0] == 3 assert clone.stat().st_ino != src.stat().st_ino # distinct inode — not a hardlink + + +def _agg_code(project: str, rel: str) -> str: # expensive (Aggregate) — would bake under cas + return f""" +from tallyman_xorq.io import read_project_file +t = read_project_file({rel!r}, project={project!r}) +expr = t.group_by("region").aggregate(total=t.price.sum()) +""" + + +def test_salt_rebuild_over_edited_source_forks_hash(project, monkeypatch): + """docs/caching.md: ``salt`` folds source digests into the entry hash (like + cas, an in-place edit forks the hash on rebuild) even though it leaves + xorq's own keys path-only.""" + monkeypatch.setenv("TALLYMAN_SOURCE_IDENTITY", "salt") + monkeypatch.setenv("TALLYMAN_SOURCE_REHASH", "1") # never serve a stale digest + assert si.mode() == "salt" + + src = data_dir(project) / "src.parquet" + _write_parquet(src, 3) + code = _read_code(project, "src.parquet") + h1 = build_and_persist(project, code).content_hash + + _write_parquet(src, 5) # user edits the source in place + h2 = build_and_persist(project, code).content_hash + + assert h1 != h2, "salt: an in-place source edit must fork the content hash on rebuild" + + +def test_salt_mode_bakes_no_snapshot_and_reads_still_work(project, monkeypatch): + """docs/caching.md: under ``salt``, ``rewrite_for_build`` bakes no cache at + all — a baked snapshot's path-only key would collide across entries with + identical paths but different content — so an expensive entry has no baked + snapshot and every read recomputes (and must still return rows).""" + from tallyman_cli.fixtures import write_shoe_orders + from tallyman_core.paths import compute_cache_dir + from tallyman_xorq.result_cache import cached_result_expr + + monkeypatch.setenv("TALLYMAN_SOURCE_IDENTITY", "salt") + write_shoe_orders(data_dir(project) / "orders.parquet", n_rows=60, seed=0) + + res = build_and_persist(project, _agg_code(project, "orders.parquet")) + + result_cache = compute_cache_dir(project) / "result_cache" + baked = list(result_cache.rglob("*.parquet")) if result_cache.exists() else [] + assert baked == [], "salt must not bake a result snapshot (path-only keys would collide)" + + df = cached_result_expr(project, res.content_hash).execute() + assert len(df) > 0 # the read recomputes live instead diff --git a/tests/test_staleness.py b/tests/test_staleness.py index 8e85c08..108c2b8 100644 --- a/tests/test_staleness.py +++ b/tests/test_staleness.py @@ -1,9 +1,12 @@ from __future__ import annotations +import os + from tallyman_cli.fixtures import write_shoe_orders from tallyman_core import data_dir from tallyman_core.aliases import get_alias -from tallyman_mcp.server import catalog_create, catalog_revise +from tallyman_mcp.server import catalog_create, catalog_revise, catalog_unalias +from tallyman_xorq.dependents import sources_of from tallyman_xorq.staleness import entry_staleness, scan @@ -39,6 +42,23 @@ def _pinned_child_code(content_hash: str) -> str: # pinned (no lineage) child o """ +def _alias_pinned_child_code(alias: str) -> str: # pinned by NAME — still follow=False + return f""" +from tallyman_xorq.io import pinned_expr_from_alias +t = pinned_expr_from_alias({alias!r}) +expr = t.mutate(doubled=t.price * 2) +""" + + +def _mixed_code(parent: str, src: str, project: str) -> str: # follows an alias AND reads a raw file + return f""" +from tallyman_xorq.io import read_project_file, tracked_expr_from_alias +t = tracked_expr_from_alias({parent!r}) +u = read_project_file({src!r}, project={project!r}) +expr = t.union(u.select("region", "price")) +""" + + def _agg_child_code(parent: str) -> str: # expensive (Aggregate) child → bakes a snapshot return f""" from tallyman_xorq.io import tracked_expr_from_alias @@ -153,3 +173,163 @@ def test_scan_distinguishes_direct_from_transitive_staleness(project, orders_par assert verdicts[b].transitively_stale is False assert verdicts[c].stale is False assert verdicts[c].transitively_stale is True + + +def test_alias_pinned_parent_is_not_stale_when_alias_advances(project, orders_parquet, monkeypatch): + """docs/reactive-recalc.md: ``pinned_expr_from_alias`` accepts an alias OR a + hash, and either form records follow=False — the deliberate opt-out. The + existing pin test uses a literal hash; this pins the by-NAME form: the alias + advancing must not make the child stale on the alias axis.""" + monkeypatch.setenv("TALLYMAN_SOURCE_IDENTITY", "cas") + monkeypatch.setenv("TALLYMAN_AUTO_RECALC", "0") + base_v1 = _hash(catalog_create("base", _base_code(project))) + child_hash = _hash(catalog_create("child", _alias_pinned_child_code("base"))) + + _hash(catalog_revise("base", _base_code_v2(project))) + assert get_alias(project, "base") != base_v1 + + v = entry_staleness(project, child_hash) + assert v.stale is False + assert [r for r in v.reasons if r.axis == "alias"] == [] + + +def test_deleted_parent_alias_is_unknown_axis_not_stale(project, orders_parquet, monkeypatch): + """docs/reactive-recalc.md: an axis that can't be evaluated — here, the + followed alias was deleted — lands in ``unknown_axes`` and never forces + ``stale: true``.""" + monkeypatch.setenv("TALLYMAN_SOURCE_IDENTITY", "cas") + catalog_create("base", _base_code(project)) + child_hash = _hash(catalog_create("child", _child_code("base"))) + + out = catalog_unalias("base") + assert "error" not in out, out + + v = entry_staleness(project, child_hash) + assert v.stale is False + # A specific gone input is reported axis-qualified ("alias:"); the bare + # axis form is reserved for a wholly-unevaluable axis (off mode). + assert "alias:base" in v.unknown_axes + + +def test_deleted_source_file_is_unknown_axis_not_stale(project, orders_parquet, monkeypatch): + """docs/reactive-recalc.md: a removed source file is the other can't-evaluate + case — unknown, not stale (``now`` can't be computed for a file that is gone).""" + monkeypatch.setenv("TALLYMAN_SOURCE_IDENTITY", "cas") + base_hash = _hash(catalog_create("base", _base_code(project))) + + (data_dir(project) / "orders.parquet").unlink() + + v = entry_staleness(project, base_hash) + assert v.stale is False + assert "source:orders.parquet" in v.unknown_axes + + +def test_recreated_alias_at_new_head_marks_child_stale(project, orders_parquet, monkeypatch): + """Alias identity is the NAME: unalias a followed parent, then re-create the + same name at a different entry. The child's recorded edge (``was`` = the old + head) no longer matches what the name resolves to now — directly stale on + the alias axis, with was/now naming both hashes.""" + monkeypatch.setenv("TALLYMAN_SOURCE_IDENTITY", "cas") + monkeypatch.setenv("TALLYMAN_AUTO_RECALC", "0") + base_v1 = _hash(catalog_create("base", _base_code(project))) + child_hash = _hash(catalog_create("child", _child_code("base"))) + + catalog_unalias("base") + base_v2 = _hash(catalog_create("base", _base_code_v2(project))) + assert base_v2 != base_v1 + + v = entry_staleness(project, child_hash) + assert v.stale is True + alias_reasons = [r for r in v.reasons if r.axis == "alias"] + assert len(alias_reasons) == 1 + assert (alias_reasons[0].ref, alias_reasons[0].was, alias_reasons[0].now) == ( + "base", + base_v1, + base_v2, + ) + + +def test_both_axes_can_be_stale_at_once(project, orders_parquet, monkeypatch): + """The two axes are independent: an entry that follows an alias AND reads a + raw file goes stale on both when both inputs move, with one reason per axis.""" + monkeypatch.setenv("TALLYMAN_SOURCE_IDENTITY", "cas") + monkeypatch.setenv("TALLYMAN_AUTO_RECALC", "0") + write_shoe_orders(data_dir(project) / "widgets.parquet", n_rows=120, seed=1) + catalog_create("base", _base_code(project)) + mixed = _hash(catalog_create("mixed", _mixed_code("base", "widgets.parquet", project))) + + _hash(catalog_revise("base", _base_code_v2(project))) # alias axis + write_shoe_orders(data_dir(project) / "widgets.parquet", n_rows=120, seed=2) # source axis + + v = entry_staleness(project, mixed) + assert v.stale is True + assert {r.axis for r in v.reasons} == {"alias", "source"} + assert {r.ref for r in v.reasons if r.axis == "alias"} == {"base"} + assert "widgets.parquet" in {r.ref for r in v.reasons if r.axis == "source"} + + +def test_cheap_parent_sources_inline_into_child_and_both_go_directly_stale( + project, orders_parquet, monkeypatch +): + """docs/reactive-recalc.md (cheap vs expensive parents): a cheap parent's + recipe is inlined into the child, so the parent's raw reads land in the + CHILD's own ``manifest.sources`` — a source edit makes the child *directly* + stale on the source axis, not merely transitively. The doc also names the + checkable invariant: a child's sources contain the union of its transitive + cheap parents' sources.""" + monkeypatch.setenv("TALLYMAN_SOURCE_IDENTITY", "cas") + base_hash = _hash(catalog_create("base", _base_code(project))) + child_hash = _hash(catalog_create("child", _child_code("base"))) + + child_sources = sources_of(project, child_hash) or {} + assert "orders.parquet" in child_sources + assert (sources_of(project, base_hash) or {}).items() <= child_sources.items() + + write_shoe_orders(data_dir(project) / "orders.parquet", n_rows=200, seed=1) + + verdicts = scan(project) + assert verdicts[base_hash].stale is True + assert verdicts[child_hash].stale is True # DIRECTLY stale, not just carried + assert {r.axis for r in verdicts[child_hash].reasons} == {"source"} + + +def test_expensive_parent_shields_child_from_source_axis(project, orders_parquet, monkeypatch): + """The flip side: a child reading an EXPENSIVE parent reads its baked + snapshot, so the parent's raw sources never enter the child's source set. + A source edit leaves the child clean (only transitively stale).""" + monkeypatch.setenv("TALLYMAN_SOURCE_IDENTITY", "cas") + base_hash = _hash(catalog_create("base", _base_code(project))) + agg_hash = _hash(catalog_create("agg", _agg_child_code("base"))) + leaf_hash = _hash(catalog_create("leaf", _const_child_code("agg"))) + + assert "orders.parquet" not in (sources_of(project, leaf_hash) or {}) + + write_shoe_orders(data_dir(project) / "orders.parquet", n_rows=200, seed=1) + + verdicts = scan(project) + assert verdicts[base_hash].stale is True # reads the file directly + assert verdicts[agg_hash].stale is True # cheap base inlines into agg + assert verdicts[leaf_hash].stale is False # shielded by agg's baked snapshot + assert verdicts[leaf_hash].transitively_stale is True + + +def test_inplace_edit_preserving_mtime_and_size_is_detected(project, orders_parquet, monkeypatch): + """docs/reactive-recalc.md: "The check forces a faithful re-read so an + in-place content swap can't be masked by a cached digest." The source-digest + memo is stat-keyed, so the masking edit is one that preserves mtime and byte + length — exactly what this simulates.""" + monkeypatch.setenv("TALLYMAN_SOURCE_IDENTITY", "cas") + monkeypatch.delenv("TALLYMAN_SOURCE_REHASH", raising=False) # the doc claim, not the escape hatch + base_hash = _hash(catalog_create("base", _base_code(project))) + + src = data_dir(project) / "orders.parquet" + st = src.stat() + raw = bytearray(src.read_bytes()) + raw[len(raw) // 2] ^= 0xFF # same length, different bytes + src.write_bytes(bytes(raw)) + os.utime(src, ns=(st.st_atime_ns, st.st_mtime_ns)) # mask the edit from stat() + assert src.stat().st_mtime_ns == st.st_mtime_ns and src.stat().st_size == st.st_size + + v = entry_staleness(project, base_hash) + assert v.stale is True + assert [r.axis for r in v.reasons] == ["source"] From 87cb3bef40a12bd64cf6d1d54ccb309b2372609e Mon Sep 17 00:00:00 2001 From: Paddy Mullen Date: Wed, 1 Jul 2026 16:24:03 -0400 Subject: [PATCH 2/3] test: failing pins for three reactive doc-vs-code holes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each of these asserts behavior the docs promise and the code does not deliver; they are expected to fail on CI until the doc-or-code call is made for each: 1. test_explicit_husk_root_is_dropped_from_the_cone — #154 head-gated the scan-driven root set and cone membership, but an explicitly passed husk root bypasses the gate and replays; under source drift the replay mints a junk sibling of the live head. mcp-server.md says roots naming no live entry are silently dropped. 2. test_scratch_follower_rebuilds_with_remap_but_no_alias_revision — reactive-recalc.md says an unnamed scratch entry rebuilds with a remap entry and zero alias revisions, but the head-gate marks a never-aliased catalog_run follower live=False, so it is never actionably stale and never recalcs. 3. test_alias_pin_is_not_silently_advanced_by_a_replay — the docs say a pin (by alias or hash) is never disturbed by recalc, but when a child's OTHER (tracked) parent forces a replay, the recipe's pinned_expr_from_alias(name) re-resolves the name to the pinned alias's new head. Hash pins are immune; alias pins silently advance. Co-Authored-By: Claude Fable 5 --- tests/test_recalc.py | 91 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/tests/test_recalc.py b/tests/test_recalc.py index f4fc087..e4155d0 100644 --- a/tests/test_recalc.py +++ b/tests/test_recalc.py @@ -495,6 +495,36 @@ def test_empty_roots_list_recomputes_nothing(project, orders_parquet, monkeypatc assert scan(project)[a1].stale is True +def test_explicit_husk_root_is_dropped_from_the_cone(project, orders_parquet, monkeypatch): + """#154 head-gated the scan-driven root set and cone membership so a + superseded old version (a husk, ``live=False``) is never replayed. The same + gate must hold for an EXPLICITLY-passed husk root — mcp-server.md: "Roots + naming no live entry are silently dropped." Replaying a source-drifted husk + mints a junk sibling entry of the live head (the #154 junk-replay shape).""" + monkeypatch.setenv("TALLYMAN_SOURCE_IDENTITY", "cas") + monkeypatch.setenv("TALLYMAN_AUTO_RECALC", "0") + a1 = _hash(catalog_create("a", _base_code(project))) + a2 = _hash(catalog_revise("a", _base_code_v2(project))) + assert a2 != a1 # a1 is now a superseded husk + assert scan(project)[a1].live is False + + _edit_source(project) # drift the raw source both versions read + + step_before = current_step(project) + entries_before = {e["content_hash"] for e in list_entries(project)} + report = recalc(project, [a1], dry_run=False) + + assert report.status == "ok" + assert report.remap == {}, ( + "an explicit husk root was replayed instead of dropped — under source " + "drift this mints a junk sibling of the live head" + ) + assert report.checkpoint_step is None + assert current_step(project) == step_before + assert get_alias(project, "a") == a2 + assert {e["content_hash"] for e in list_entries(project)} == entries_before # no junk sibling + + # --------------------------------------------------------------------------- # alias bookkeeping through the walk: multi-alias heads, scratch entries, pins # --------------------------------------------------------------------------- @@ -522,6 +552,67 @@ def test_rebuilt_head_with_two_aliases_advances_both(project, orders_parquet, mo assert history_for(project, "bb") == [h1, h2] +def test_scratch_follower_rebuilds_with_remap_but_no_alias_revision(project, orders_parquet, monkeypatch): + """docs/reactive-recalc.md: "A rebuilt entry that has no alias on its head — + an unnamed scratch entry ... — produces a new hash and a ``remap`` entry but + ZERO alias revisions." So a ``catalog_run`` scratch follower must scan stale + and rebuild like any follower, minus the alias bookkeeping.""" + monkeypatch.setenv("TALLYMAN_SOURCE_IDENTITY", "cas") + monkeypatch.setenv("TALLYMAN_AUTO_RECALC", "0") + catalog_create("a", _base_code(project)) + s1 = _hash(catalog_run(_child_code("a"))) # unnamed scratch follower + assert alias_for_hash(project, s1) is None + + catalog_revise("a", _base_code_v2(project)) + verdict = scan(project)[s1] + assert verdict.stale is True, ( + "a scratch follower must be actionably stale when its followed alias advances " + f"(got stale={verdict.stale}, live={verdict.live}: the head-gate also excludes " + "never-aliased scratch entries, contradicting the documented scratch-rebuild " + "semantics of reactive-recalc.md)" + ) + + report = recalc(project, [s1], dry_run=False) + + assert report.status == "ok" + s2 = report.remap.get(s1) + assert s2 is not None and s2 != s1 + assert alias_for_hash(project, s2) is None # rebuilt, but zero alias revisions + parents = next(e for e in list_entries(project) if e["content_hash"] == s2)["parents"] + assert parents[0]["hash"] == get_alias(project, "a") + + +def test_alias_pin_is_not_silently_advanced_by_a_replay(project, orders_parquet, monkeypatch): + """docs/reactive-recalc.md promises a pinned parent (by alias OR hash) means + the child "stays on that exact revision" and is "never disturbed by recalc". + The hard case is a child with one tracked parent and one alias-pinned parent: + when the TRACKED parent advances and forces a replay, the recipe's + ``pinned_expr_from_alias("p")`` re-runs — it must keep resolving to the + revision recorded at build, not silently absorb p's new head.""" + monkeypatch.setenv("TALLYMAN_SOURCE_IDENTITY", "cas") + monkeypatch.setenv("TALLYMAN_AUTO_RECALC", "0") + catalog_create("a", _base_code(project)) + p1 = _hash(catalog_create("p", _filtered_code(project, 100))) + child1 = _hash(catalog_create("child", _union_tracked_pinned_code("a", "p"))) + + # Advance the PINNED alias first — the pin means the child must not care. + catalog_revise("p", _filtered_code(project, 150)) + assert get_alias(project, "p") != p1 + assert entry_staleness(project, child1).stale is False + + # Now advance the TRACKED parent, forcing the child to replay. The new + # recipe keeps the (region, price) schema so the union still plans. + catalog_revise("a", _filtered_code(project, 50)) + report = recalc(project, [child1], dry_run=False) + + assert report.status == "ok" + child2 = report.remap[child1] + pinned_parents = [pr for pr in parents_of(project, child2) if pr.follow is False] + assert [pr.hash for pr in pinned_parents] == [p1], ( + "the alias pin was silently advanced to p's new head by the replay" + ) + + def test_replay_with_deleted_parent_alias_fails_cleanly(project, orders_parquet, monkeypatch): """A deleted followed alias is an unknown axis (not stale), so a scan never roots there — but an explicit recalc still replays the child, whose From 618f2b5f1abdcc24e20940263be604e7751f95d5 Mon Sep 17 00:00:00 2001 From: Paddy Mullen Date: Thu, 2 Jul 2026 09:29:19 -0400 Subject: [PATCH 3/3] fix(reactive): close the three doc-vs-code holes the pins exposed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/tallyman_xorq/io.py | 58 ++++++++++++++++++++++++++++++---- src/tallyman_xorq/recalc.py | 50 ++++++++++++++++++++--------- src/tallyman_xorq/staleness.py | 38 ++++++++++++++-------- 3 files changed, 110 insertions(+), 36 deletions(-) diff --git a/src/tallyman_xorq/io.py b/src/tallyman_xorq/io.py index ca2f503..a34433c 100644 --- a/src/tallyman_xorq/io.py +++ b/src/tallyman_xorq/io.py @@ -9,6 +9,8 @@ from __future__ import annotations +import contextlib +import contextvars from pathlib import Path from tallyman_core import data_dir, entry_dir, get_alias, resolve_project @@ -18,6 +20,30 @@ class ProjectDataNotFound(FileNotFoundError): pass +# A replay-time freeze for alias-pinned parents (#160). ``pinned_expr_from_alias("p")`` +# stores the alias *name* in the recipe, so a naive replay re-resolves the name to p's +# CURRENT head and the pin silently advances — the docs promise a pin "stays on that +# exact revision" and is "never disturbed by recalc". During a recalc replay the walk +# knows the revision the pin resolved to at build (``manifest.parents[i].hash``) and +# sets this map ``{alias_name: frozen_hash}`` so ``pinned_expr_from_alias`` re-resolves +# to the recorded revision instead of the live head. A pin by *hash* stores the hash +# literally and never consults this map — it is already replay-durable. +_PIN_FREEZE: contextvars.ContextVar[dict[str, str] | None] = contextvars.ContextVar("tallyman_pin_freeze", default=None) + + +@contextlib.contextmanager +def pin_freeze(mapping: dict[str, str]): + """Freeze alias-pinned parents to their recorded revisions for the duration of a + recalc replay (#160). ``mapping`` is ``{alias_name: content_hash}``; while it is + active, ``pinned_expr_from_alias(name)`` resolves ``name`` to ``content_hash`` + rather than the alias's current head.""" + token = _PIN_FREEZE.set(mapping) + try: + yield + finally: + _PIN_FREEZE.reset(token) + + def project_path(rel_path: str, project: str | None = None, must_exist: bool = True) -> Path: """Resolve `rel_path` against the project's data dir. Raises if absent. @@ -99,10 +125,19 @@ def read_project_file(rel_path: str, project: str | None = None): # Covers the types the MCP documents for tallyman_read_csv; an unmapped type # raises rather than silently inferring, so a schema mistake fails loudly. _IBIS_TO_POLARS = { - "int8": "Int8", "int16": "Int16", "int32": "Int32", "int64": "Int64", - "uint8": "UInt8", "uint16": "UInt16", "uint32": "UInt32", "uint64": "UInt64", - "float32": "Float32", "float64": "Float64", - "string": "String", "bool": "Boolean", "boolean": "Boolean", + "int8": "Int8", + "int16": "Int16", + "int32": "Int32", + "int64": "Int64", + "uint8": "UInt8", + "uint16": "UInt16", + "uint32": "UInt32", + "uint64": "UInt64", + "float32": "Float32", + "float64": "Float64", + "string": "String", + "bool": "Boolean", + "boolean": "Boolean", "date": "Date", } @@ -273,7 +308,7 @@ def _normalize_schema(spec, header: list[str], *, reserved: tuple[str, ...] = () f"{len(speccable)} {list(speccable)}.{reserved_hint}" ) if rest_dtype is None and len(cells) != len(speccable): - uncovered = list(speccable[len(cells):]) + uncovered = list(speccable[len(cells) :]) raise ValueError( f"tallyman_read_csv: schema is not total — it leaves column(s) {uncovered} unspecified. " 'Name every column, or close the spec with ("&rest", "infer").' @@ -289,7 +324,7 @@ def _normalize_schema(spec, header: list[str], *, reserved: tuple[str, ...] = () inferred = True else: overrides[orig] = pl_dt - for orig in speccable[len(cells):]: # the &rest tail keeps its header name + for orig in speccable[len(cells) :]: # the &rest tail keeps its header name out_names.append(orig) pl_dt = _resolve_spec_dtype(rest_dtype) if pl_dt is None: @@ -717,7 +752,16 @@ def pinned_expr_from_alias(alias_or_hash: str, project: str | None = None): proj = resolve_project(project) is_hash = entry_dir(proj, alias_or_hash).exists() - content_hash = alias_or_hash if is_hash else get_alias(proj, alias_or_hash) + if is_hash: + content_hash = alias_or_hash # a literal hash pin is already replay-durable + else: + # An alias-name pin: during a recalc replay, resolve to the revision recorded + # at build (the freeze map) so the pin is not silently advanced by the replay + # (#160); otherwise resolve the alias's current head as usual. + frozen = _PIN_FREEZE.get() + content_hash = frozen.get(alias_or_hash) if frozen else None + if content_hash is None: + content_hash = get_alias(proj, alias_or_hash) if content_hash is None or not entry_dir(proj, content_hash).exists(): raise ProjectDataNotFound(f"catalog entry {alias_or_hash!r} not found in project {proj!r}") content_hash = _resolve_noncyclic_hash(proj, alias_or_hash, content_hash) diff --git a/src/tallyman_xorq/recalc.py b/src/tallyman_xorq/recalc.py index bf34015..37a1823 100644 --- a/src/tallyman_xorq/recalc.py +++ b/src/tallyman_xorq/recalc.py @@ -46,7 +46,8 @@ from tallyman_core.errors import errors_by_hash, record_error from tallyman_core.paths import entry_dir, entry_manifest_path, project_dir from tallyman_xorq.build import BuildError, build_and_persist -from tallyman_xorq.dependents import DependencyCycleError, build_dag, descendant_cone +from tallyman_xorq.dependents import DependencyCycleError, build_dag, descendant_cone, parents_of +from tallyman_xorq.io import pin_freeze from tallyman_xorq.portable import PLACEHOLDER from tallyman_xorq.staleness import StaleReason, StaleVerdict, scan @@ -102,6 +103,18 @@ def _recipe_code(project: str, content_hash: str) -> str: return code.replace(PLACEHOLDER, str(project_dir(project))) +def _pinned_alias_freeze(project: str, content_hash: str) -> dict[str, str]: + """``{alias_name: recorded_hash}`` for this entry's alias-pinned parents (#160). + + A ``follow=False`` parent whose ``ref`` is an alias *name* (not the pinned hash + itself) was resolved to ``ref``'s head at build; on replay the recipe re-runs + ``pinned_expr_from_alias(ref)`` and would re-resolve the name to the alias's *new* + head, silently advancing the pin. Freezing ``ref -> hash`` keeps the pin on its + recorded revision. A hash pin (``ref == hash``) stores the hash literally in the + recipe and is already replay-durable, so it needs no freeze entry.""" + return {p.ref: p.hash for p in parents_of(project, content_hash) if not p.follow and p.ref != p.hash} + + def _aliases_heading(project: str, content_hash: str) -> list[str]: """Every alias whose *current* head is ``content_hash`` (usually one, never 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 return "unchanged" # pinned to an out-of-cone parent, or a non-stale root -def _live_cone(cone: list[str], roots: list[str], verdicts: dict[str, StaleVerdict]) -> list[str]: - """The cone with dragged-in husks removed (#154). ``descendant_cone`` re-expands - from a stale head through *every* recorded parent edge, so a superseded husk that - follows that head (via a since-advanced alias) is pulled back in even though the - head-gate dropped it from the root set. Replaying such a husk re-resolves its - followed alias to the advanced head and manufactures a junk entry that heads no - alias and re-points nothing — the f2dd/cb24 regression. Drop every non-head cone - member; an explicitly-named root is exempt (a user-supplied root is a deliberate - request, not scan-driven drag-in — the revise/recalc defaults never pass one). +def _live_cone(cone: list[str], verdicts: dict[str, StaleVerdict]) -> list[str]: + """The cone with husks removed (#154). ``descendant_cone`` re-expands from a stale + head through *every* recorded parent edge, so a superseded husk that follows that + head (via a since-advanced alias) is pulled back in. Replaying such a husk + re-resolves its followed alias to the advanced head and manufactures a junk entry + that heads no alias and re-points nothing — the f2dd/cb24 regression. + + Drop every husk (``live=False``), whether it was dragged in by the cone expansion + OR named explicitly as a root: mcp-server.md promises "roots naming no live entry + are silently dropped", and the scan's own ``live=False`` verdict for the husk is + the authority on that. An explicit husk root replayed under source drift mints the + same junk sibling as the scan-driven path, so the gate must cover both. Safe because a *live* entry can depend on a husk only through a ``follow=False`` hash pin (nothing can follow a headless husk by name), and a hash pin replays to the same parent whether or not the husk itself was rebuilt — so nothing downstream is stranded by skipping it. Order is preserved, so the topological walk stays valid. """ - roots_set = set(roots) - return [h for h in cone if h in roots_set or verdicts[h].live] + return [h for h in cone if verdicts[h].live] 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 # the project), then index into it for the cone. Keeps the staleness # semantics in one tested place rather than re-deriving them here. verdicts = scan(project) - cone = _live_cone(cone, roots, verdicts) # drop dragged-in husks so preview matches the real walk (#154) + cone = _live_cone(cone, verdicts) # drop husks so preview matches the real walk (#154) cone_set = set(cone) dag = build_dag(project) entries = [ @@ -222,7 +237,7 @@ def _replay_cone( if verdicts is None: verdicts = scan(project) - cone = _live_cone(cone, roots, verdicts) # a husk dragged into a stale head's cone must not replay into junk (#154) + cone = _live_cone(cone, verdicts) # a husk (dragged-in or explicitly named) must not replay into junk (#154) remap: dict[str, str] = {} entries: list[RecalcEntry] = [] status = "ok" @@ -246,8 +261,13 @@ def _replay_cone( # Resolve the heading alias(es) BEFORE re-pointing anything for this entry, # so a yet-to-be-processed downstream alias is read at its pre-recalc head. heading = _aliases_heading(project, old_hash) + # Freeze alias-pinned parents to the revision they resolved to at build, so a + # replay forced by a *tracked* parent advancing does not silently re-resolve + # `pinned_expr_from_alias("p")` to p's new head (#160). + freeze = _pinned_alias_freeze(project, old_hash) try: - result = build_and_persist(project, _recipe_code(project, old_hash)) + with pin_freeze(freeze): + result = build_and_persist(project, _recipe_code(project, old_hash)) except BuildError as exc: # Persist the failure (outside the catalog repo, so it survives # reset_to) keyed by the failed hash — the orphan-stale tie-back. diff --git a/src/tallyman_xorq/staleness.py b/src/tallyman_xorq/staleness.py index ccdda4d..9a76091 100644 --- a/src/tallyman_xorq/staleness.py +++ b/src/tallyman_xorq/staleness.py @@ -118,29 +118,39 @@ def entry_staleness(project: str, content_hash: str) -> StaleVerdict: def scan(project: str) -> dict[str, StaleVerdict]: """Actionable staleness for every live entry, tagging direct vs transitive. - An entry is **directly** stale only when it is a *current alias head* whose own - recorded input moved (#154). A superseded historical version pins alias heads - that have since advanced, so its inputs read as moved forever — but it heads no - alias, so recomputing it re-points nothing (and, for a recipe shape no current - head uses, manufactures a junk entry), and it is not an invariant break to flag. - Such a non-head is marked ``live=False`` and forced ``stale=False`` here (its - ``reasons`` are kept for forensics). A **transitively** stale entry is a (not - directly stale) descendant of a directly-stale head in the dependency cone. + An entry is **live** unless it is a *superseded husk* — a version that appears + in some alias's history but is no longer that alias's head (#154). A husk pins + alias heads that have since advanced, so its inputs read as moved forever, yet + recomputing it re-points nothing (and, for a recipe shape no current head uses, + manufactures a junk entry). Such a husk is marked ``live=False`` and forced + ``stale=False`` here (its ``reasons`` are kept for forensics). + + A never-aliased *scratch* entry (a ``catalog_run`` follower that appears in no + alias history) is **not** a husk: nothing has superseded it, so it stays live + and goes actionably stale when a parent it follows advances — it rebuilds like + any follower, minus the alias bookkeeping (reactive-recalc.md's scratch-rebuild + semantics). The gate is "superseded", not "is a head". + + A **transitively** stale entry is a (not directly stale) descendant of a + directly-stale head in the dependency cone. ``entry_staleness`` stays the raw per-entry primitive — did *this* entry's inputs - move, regardless of headship; this function layers the head-reachability gate - that makes ``stale`` mean *actionable*. The verdict dict still keys **every** - entry (heads and husks alike), because the recalc replay indexes cone members - that are not themselves heads (a hash-pinned child). + move, regardless of headship; this function layers the husk gate that makes + ``stale`` mean *actionable*. The verdict dict still keys **every** entry (live + entries and husks alike), because the recalc replay indexes cone members that + are not themselves heads (a hash-pinned child). """ heads = set(aliases.load_aliases(project).values()) # current alias heads, read once + # A husk is a hash that appears in some alias history but is no longer a head; + # a never-aliased scratch entry appears in no history, so it is not a husk. + superseded = {h for hist in aliases.load_history(project).values() for h in hist} - heads verdicts = { entry["content_hash"]: entry_staleness(project, entry["content_hash"]) for entry in list_entries(project) } for content_hash, verdict in verdicts.items(): - verdict.live = content_hash in heads + verdict.live = content_hash not in superseded if not verdict.live: - verdict.stale = False # a non-head is never actionably stale (#154) + verdict.stale = False # a superseded husk is never actionably stale (#154) directly_stale = [h for h, v in verdicts.items() if v.stale] if directly_stale: for content_hash in descendant_cone(project, directly_stale):