Skip to content

Commit 8dd85fd

Browse files
paddymulclaude
andcommitted
test(#89): reactive consumer — dependents reader + staleness predicates
Failing-first (TDD): tests/test_dependents.py and tests/test_staleness.py exercise the read-only half of the reactive consumer (epic #89), which does not exist yet — both error on import of tallyman_xorq.dependents / tallyman_xorq.staleness. dependents: parents_of / build_dag / dependents_index / descendant_cone over manifest.parents, covered with synthetic manifests (chain, diamond, mixed follow/pin, cycle-raises, leaf). staleness: entry_staleness's two axes (followed-alias-advanced, source-drifted) and scan's direct-vs-transitive tagging, covered with real catalog_create / catalog_revise builds — alias advance marks a follow child stale, a hash pin does not, an in-place source edit marks the entry stale under cas, off mode reports the source axis unknown (not fresh), and a source edit under a chain root tags descendants transitively stale. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 1f8d7b1 commit 8dd85fd

2 files changed

Lines changed: 238 additions & 0 deletions

File tree

tests/test_dependents.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
from __future__ import annotations
2+
3+
import pytest
4+
from tallyman_xorq.dependents import (
5+
DependencyCycleError,
6+
build_dag,
7+
dependents_index,
8+
descendant_cone,
9+
parents_of,
10+
)
11+
12+
from tallyman_core.manifest import Manifest, ParentRef, write_manifest
13+
from tallyman_core.paths import entry_dir
14+
15+
16+
def _write_entry(project: str, content_hash: str, parents=None) -> str:
17+
"""Write a minimal entry (manifest.json only) with the given parent edges.
18+
19+
The dependents reader walks ``manifest.parents`` and never executes a
20+
recipe, so a hand-written manifest is a faithful and fast graph fixture —
21+
no xorq build required. ``parents`` is a list of ``(hash, ref, follow)``.
22+
"""
23+
d = entry_dir(project, content_hash)
24+
d.mkdir(parents=True, exist_ok=True)
25+
refs = [ParentRef(hash=h, ref=r, follow=f) for (h, r, f) in (parents or [])]
26+
write_manifest(d, Manifest(content_hash=content_hash, project=project, parents=refs or None))
27+
return content_hash
28+
29+
30+
def test_parents_of_root_is_empty(project):
31+
_write_entry(project, "root")
32+
assert parents_of(project, "root") == []
33+
34+
35+
def test_parents_of_returns_recorded_edges(project):
36+
_write_entry(project, "root")
37+
_write_entry(project, "child", [("root", "root", True)])
38+
edges = parents_of(project, "child")
39+
assert [(e.hash, e.ref, e.follow) for e in edges] == [("root", "root", True)]
40+
41+
42+
def test_build_dag_maps_every_live_entry(project):
43+
_write_entry(project, "a")
44+
_write_entry(project, "b", [("a", "a", True)])
45+
dag = build_dag(project)
46+
assert set(dag) == {"a", "b"}
47+
assert dag["a"] == []
48+
assert [e.hash for e in dag["b"]] == ["a"]
49+
50+
51+
def test_dependents_index_inverts_the_dag(project):
52+
_write_entry(project, "a")
53+
_write_entry(project, "b", [("a", "a", True)])
54+
_write_entry(project, "c", [("a", "a", True)])
55+
idx = dependents_index(project)
56+
assert idx == {"a": {"b", "c"}}
57+
58+
59+
def test_descendant_cone_three_level_chain_is_topologically_ordered(project):
60+
_write_entry(project, "a")
61+
_write_entry(project, "b", [("a", "a", True)])
62+
_write_entry(project, "c", [("b", "b", True)])
63+
assert descendant_cone(project, ["a"]) == ["a", "b", "c"]
64+
65+
66+
def test_descendant_cone_diamond_orders_parents_before_children(project):
67+
_write_entry(project, "a")
68+
_write_entry(project, "b", [("a", "a", True)])
69+
_write_entry(project, "c", [("a", "a", True)])
70+
_write_entry(project, "d", [("b", "b", True), ("c", "c", True)])
71+
cone = descendant_cone(project, ["a"])
72+
assert set(cone) == {"a", "b", "c", "d"}
73+
assert cone[0] == "a"
74+
assert cone[-1] == "d"
75+
assert cone.index("b") < cone.index("d")
76+
assert cone.index("c") < cone.index("d")
77+
78+
79+
def test_descendant_cone_includes_both_pinned_and_followed_children(project):
80+
_write_entry(project, "a")
81+
_write_entry(project, "followed", [("a", "a", True)])
82+
_write_entry(project, "pinned", [("a", "a", False)])
83+
cone = descendant_cone(project, ["a"])
84+
assert set(cone) == {"a", "followed", "pinned"}
85+
86+
87+
def test_descendant_cone_detects_a_cycle_instead_of_hanging(project):
88+
_write_entry(project, "a", [("b", "b", True)])
89+
_write_entry(project, "b", [("a", "a", True)])
90+
with pytest.raises(DependencyCycleError):
91+
descendant_cone(project, ["a"])
92+
93+
94+
def test_descendant_cone_of_a_leaf_is_just_the_leaf(project):
95+
_write_entry(project, "solo")
96+
assert descendant_cone(project, ["solo"]) == ["solo"]

tests/test_staleness.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
from __future__ import annotations
2+
3+
from tallyman_cli.fixtures import write_shoe_orders
4+
from tallyman_core import data_dir
5+
from tallyman_core.aliases import get_alias
6+
from tallyman_mcp.server import catalog_create, catalog_revise
7+
from tallyman_xorq.staleness import entry_staleness, scan
8+
9+
10+
def _base_code(project: str) -> str: # from_project root over orders.parquet
11+
return f"""
12+
from tallyman_xorq.io import from_project
13+
t = from_project("orders.parquet", project={project!r})
14+
expr = t.select("region", "price")
15+
"""
16+
17+
18+
def _base_code_v2(project: str) -> str: # a different graph → a new content hash
19+
return f"""
20+
from tallyman_xorq.io import from_project
21+
t = from_project("orders.parquet", project={project!r})
22+
expr = t.select("region", "price").mutate(extra=1)
23+
"""
24+
25+
26+
def _child_code(parent: str) -> str: # from_catalog child (alias name or literal hash)
27+
return f"""
28+
from tallyman_xorq.io import from_catalog
29+
t = from_catalog({parent!r})
30+
expr = t.mutate(doubled=t.price * 2)
31+
"""
32+
33+
34+
def _agg_child_code(parent: str) -> str: # expensive (Aggregate) child → bakes a snapshot
35+
return f"""
36+
from tallyman_xorq.io import from_catalog
37+
t = from_catalog({parent!r})
38+
expr = t.group_by("region").aggregate(total=t.price.sum())
39+
"""
40+
41+
42+
def _const_child_code(parent: str) -> str: # cheap child, schema-independent
43+
return f"""
44+
from tallyman_xorq.io import from_catalog
45+
t = from_catalog({parent!r})
46+
expr = t.mutate(flag=1)
47+
"""
48+
49+
50+
def _hash(result: dict) -> str:
51+
assert "hash" in result, result
52+
return result["hash"]
53+
54+
55+
def test_followed_alias_advance_marks_child_stale(project, orders_parquet, monkeypatch):
56+
monkeypatch.setenv("TALLYMAN_SOURCE_IDENTITY", "cas")
57+
base_v1 = _hash(catalog_create("base", _base_code(project)))
58+
child_hash = _hash(catalog_create("child", _child_code("base")))
59+
60+
# The followed edge still matches the alias head — not stale yet.
61+
assert entry_staleness(project, child_hash).stale is False
62+
63+
_hash(catalog_revise("base", _base_code_v2(project)))
64+
base_v2 = get_alias(project, "base")
65+
assert base_v2 != base_v1
66+
67+
v = entry_staleness(project, child_hash)
68+
assert v.stale is True
69+
alias_reasons = [r for r in v.reasons if r.axis == "alias"]
70+
assert len(alias_reasons) == 1
71+
assert (alias_reasons[0].ref, alias_reasons[0].was, alias_reasons[0].now) == (
72+
"base",
73+
base_v1,
74+
base_v2,
75+
)
76+
77+
78+
def test_hash_pinned_parent_is_not_stale_when_alias_advances(project, orders_parquet, monkeypatch):
79+
monkeypatch.setenv("TALLYMAN_SOURCE_IDENTITY", "cas")
80+
base_v1 = _hash(catalog_create("base", _base_code(project)))
81+
# A literal-hash argument is recorded follow=False (a pin), so the alias
82+
# advancing must not make this child stale.
83+
child_hash = _hash(catalog_create("child", _child_code(base_v1)))
84+
85+
_hash(catalog_revise("base", _base_code_v2(project)))
86+
assert get_alias(project, "base") != base_v1
87+
88+
v = entry_staleness(project, child_hash)
89+
assert v.stale is False
90+
assert [r for r in v.reasons if r.axis == "alias"] == []
91+
92+
93+
def test_source_edit_marks_entry_stale_under_cas(project, orders_parquet, monkeypatch):
94+
monkeypatch.setenv("TALLYMAN_SOURCE_IDENTITY", "cas")
95+
base_hash = _hash(catalog_create("base", _base_code(project)))
96+
assert entry_staleness(project, base_hash).stale is False
97+
98+
# Rewrite the source in place with different data (new bytes, new mtime).
99+
write_shoe_orders(data_dir(project) / "orders.parquet", n_rows=200, seed=1)
100+
101+
v = entry_staleness(project, base_hash)
102+
assert v.stale is True
103+
source_reasons = [r for r in v.reasons if r.axis == "source"]
104+
assert len(source_reasons) == 1
105+
assert source_reasons[0].ref == "orders.parquet"
106+
107+
108+
def test_off_mode_reports_source_axis_unknown_not_fresh(project, orders_parquet, monkeypatch):
109+
monkeypatch.setenv("TALLYMAN_SOURCE_IDENTITY", "off")
110+
base_hash = _hash(catalog_create("base", _base_code(project)))
111+
112+
v = entry_staleness(project, base_hash)
113+
assert v.stale is False
114+
assert "source" in v.unknown_axes
115+
116+
# off mode records no source digests, so it cannot see drift — unknown, not stale.
117+
write_shoe_orders(data_dir(project) / "orders.parquet", n_rows=200, seed=1)
118+
v2 = entry_staleness(project, base_hash)
119+
assert v2.stale is False
120+
assert "source" in v2.unknown_axes
121+
122+
123+
def test_scan_distinguishes_direct_from_transitive_staleness(project, orders_parquet, monkeypatch):
124+
monkeypatch.setenv("TALLYMAN_SOURCE_IDENTITY", "cas")
125+
a_v1 = _hash(catalog_create("a", _base_code(project)))
126+
# b is an expensive (Aggregate) intermediate: reconstructing it reads its
127+
# baked snapshot rather than re-running its recipe, so its from_catalog("a")
128+
# edge does not leak into c. (A cheap intermediate re-runs inline, so c would
129+
# follow "a" directly and there would be no transitive-only node.)
130+
b = _hash(catalog_create("b", _agg_child_code("a")))
131+
c = _hash(catalog_create("c", _const_child_code("b")))
132+
133+
# Advance alias "a". b follows "a" so it is DIRECTLY stale; c follows "b"
134+
# (unchanged), so it is only TRANSITIVELY stale via its ancestor b.
135+
_hash(catalog_revise("a", _base_code_v2(project)))
136+
assert get_alias(project, "a") != a_v1
137+
138+
verdicts = scan(project)
139+
assert verdicts[b].stale is True
140+
assert verdicts[b].transitively_stale is False
141+
assert verdicts[c].stale is False
142+
assert verdicts[c].transitively_stale is True

0 commit comments

Comments
 (0)