|
21 | 21 | from tallyman_core.config import auto_recalc_enabled, set_auto_recalc |
22 | 22 | from tallyman_core.errors import error_for_hash |
23 | 23 | 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 |
25 | 25 | from tallyman_xorq.build import list_entries |
| 26 | +from tallyman_xorq.dependents import descendant_cone |
| 27 | +from tallyman_xorq.recalc import followers_of |
26 | 28 | from tallyman_xorq.staleness import scan |
27 | 29 |
|
28 | 30 | # --------------------------------------------------------------------------- |
@@ -473,3 +475,133 @@ def counting_scan(p): |
473 | 475 | monkeypatch.setattr(recalc_mod, "scan", counting_scan) |
474 | 476 | catalog_revise("a", _src_code_v2(project)) |
475 | 477 | 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")} |
0 commit comments