@@ -872,6 +872,224 @@ def _just_raise():
872872 assert not p .exists ()
873873
874874
875+ # ---------------------------------------------------------------------------
876+ # #115: digest-pinned reconstruction — a cold read must serve the bytes the entry
877+ # was BUILT from (the frozen data/.cas/<digest> clone), not a re-digest of the
878+ # live source after it is edited in place. #86 made *build* identity content-aware
879+ # (a rebuild forks the hash) but left cold *reconstruction* re-running the recipe's
880+ # from_project over the live file. These pin the closure for both #74 hazards: a
881+ # cheap entry's cold read, and an expensive entry self-healing an evicted snapshot.
882+ # ---------------------------------------------------------------------------
883+
884+
885+ def _write_ints (path , values ) -> None :
886+ import pandas as pd
887+
888+ pd .DataFrame ({"x" : list (values )}).to_parquet (path )
889+
890+
891+ def _src_read_code (project : str , rel : str ) -> str : # cheap: a bare project read
892+ return f"from tallyman_xorq.io import from_project\n expr = from_project({ rel !r} , project={ project !r} )\n "
893+
894+
895+ def _src_group_code (project : str , rel : str ) -> str : # expensive: group_by → Aggregate → baked snapshot
896+ # order_by('x') pins a deterministic row order so the #83 result_digest is stable
897+ # build-to-self-heal (an unordered aggregate would reshuffle and read as drift).
898+ return (
899+ "from tallyman_xorq.io import from_project\n "
900+ f"t = from_project({ rel !r} , project={ project !r} )\n "
901+ "expr = t.group_by('x').aggregate(c=t.count()).order_by('x')\n "
902+ )
903+
904+
905+ def test_cas_cold_read_after_inplace_edit_serves_built_bytes (project , monkeypatch ):
906+ """#115: a cheap entry's cold read serves the rows it was built from, even after
907+ the source is edited in place.
908+
909+ Pre-#115 the cold read re-imports the recipe and re-runs from_project, which under
910+ cas re-digests the now-edited live file and clones it under a NEW digest — serving
911+ the edited rows under the entry's ORIGINAL content_hash. Digest-pinned
912+ reconstruction resolves to the frozen .cas clone the entry recorded instead.
913+ """
914+ from tallyman_core import data_dir
915+ from tallyman_xorq import build_and_persist
916+ from tallyman_xorq import source_identity as si
917+ from tallyman_xorq .result_cache import cached_result_expr
918+
919+ monkeypatch .setenv ("TALLYMAN_PROJECT" , project )
920+ monkeypatch .setenv ("TALLYMAN_SOURCE_IDENTITY" , "cas" ) # several sibling tests force off
921+ assert si .mode () == "cas"
922+
923+ src = data_dir (project ) / "src.parquet"
924+ _write_ints (src , [0 , 1 , 2 ])
925+ h = build_and_persist (project , _src_read_code (project , "src.parquet" )).content_hash
926+
927+ # Edit the source in place AFTER building. The 3→5 row edit changes the file
928+ # size, so the stat-keyed digest memo invalidates without TALLYMAN_SOURCE_REHASH.
929+ _write_ints (src , [0 , 1 , 2 , 3 , 4 ])
930+ cached_result_expr .cache_clear () # force a genuinely COLD read (re-import the recipe)
931+
932+ df = cached_result_expr (project , h ).execute ()
933+ # Assert on VALUES, not just the count, so a fix that read some other 3-row file
934+ # can't false-green: only the frozen clone holds exactly {0,1,2} now.
935+ assert sorted (df ["x" ].tolist ()) == [0 , 1 , 2 ]
936+
937+
938+ def test_cas_expensive_self_heal_after_edit_serves_built_bytes (project , monkeypatch , caplog ):
939+ """#115: an expensive entry self-healing an evicted snapshot recomputes from the
940+ FROZEN source, not the edited live file.
941+
942+ The self-heal re-imports the recipe (cold _resolve_result_plan) and re-executes
943+ the baked op; pre-#115 that recompute read the edited source, so the snapshot
944+ self-healed to different bytes under the original content_hash — exactly the #83
945+ UNFAITHFUL signal. Pinned reconstruction reads the frozen clone, so the heal is
946+ faithful and the warning does not fire.
947+ """
948+ import logging
949+
950+ from tallyman_core import data_dir
951+ from tallyman_xorq import build_and_persist
952+ from tallyman_xorq import source_identity as si
953+ from tallyman_xorq .result_cache import baked_snapshot_path , cached_result_expr
954+
955+ monkeypatch .setenv ("TALLYMAN_PROJECT" , project )
956+ monkeypatch .setenv ("TALLYMAN_SOURCE_IDENTITY" , "cas" )
957+ assert si .mode () == "cas"
958+
959+ src = data_dir (project ) / "src.parquet"
960+ _write_ints (src , [0 , 1 , 2 ])
961+ h = build_and_persist (project , _src_group_code (project , "src.parquet" )).content_hash
962+
963+ # Capture the snapshot path BEFORE editing. Post-edit, baked_snapshot_path
964+ # re-imports the recipe and (on current code) re-digests the edited source, so it
965+ # returns a NEW, non-existent path — unlinking that would error in setup, not test
966+ # the fix.
967+ p = baked_snapshot_path (project , h )
968+ assert p is not None and p .exists ()
969+
970+ _write_ints (src , [0 , 1 , 2 , 3 , 4 ]) # edit the source in place
971+ p .unlink () # evict the baked snapshot → the next read must self-heal
972+ cached_result_expr .cache_clear ()
973+
974+ caplog .clear ()
975+ with caplog .at_level (logging .WARNING , logger = "tallyman.perf" ):
976+ df = cached_result_expr (project , h ).execute ()
977+
978+ # group_by('x') over the frozen {0,1,2} → 3 groups; the edited {0,1,2,3,4} would
979+ # give 5. The self-heal must recompute from the frozen clone.
980+ assert sorted (df ["x" ].tolist ()) == [0 , 1 , 2 ]
981+ assert p .exists () # the heal rewrote the ORIGINAL-keyed snapshot, not a drifted one
982+ # The #83 faithfulness check must NOT fire: the recompute matches the build digest
983+ # because it read the frozen bytes (pre-fix it self-healed to edited bytes and
984+ # logged UNFAITHFUL).
985+ msgs = [r .getMessage () for r in caplog .records if r .name == "tallyman.perf" ]
986+ assert not any ("UNFAITHFUL" in m for m in msgs ), msgs
987+
988+
989+ def test_cas_nested_chain_reconstruction_pins_each_entrys_sources (project , monkeypatch ):
990+ """#115: nested reconstruction restores the per-entry source map across the chain.
991+
992+ B unions from_catalog(A) with its own from_project(b). A cold read of B
993+ reconstructs A — whose from_project must resolve to A's recorded a.parquet — then
994+ reads B's own recorded b.parquet. The frozen-source contextvar is overwritten for
995+ A's reconstruction and restored for B's remaining reads, so each from_project
996+ pins ITS OWN built bytes even after both sources are edited in place.
997+ """
998+ from tallyman_core import data_dir
999+ from tallyman_xorq import source_identity as si
1000+ from tallyman_xorq .result_cache import cached_result_expr
1001+
1002+ monkeypatch .setenv ("TALLYMAN_PROJECT" , project )
1003+ monkeypatch .setenv ("TALLYMAN_SOURCE_IDENTITY" , "cas" )
1004+ assert si .mode () == "cas"
1005+
1006+ srca = data_dir (project ) / "a.parquet"
1007+ srcb = data_dir (project ) / "b.parquet"
1008+ _write_ints (srca , [0 , 1 , 2 ])
1009+ _write_ints (srcb , [10 , 11 ])
1010+
1011+ catalog_create ("pa" , _src_read_code (project , "a.parquet" )) # cheap parent over a.parquet
1012+ b_code = (
1013+ "from tallyman_xorq.io import from_project, from_catalog\n "
1014+ "a = from_catalog('pa')\n "
1015+ f"b = from_project('b.parquet', project={ project !r} )\n "
1016+ "expr = a.union(b)\n "
1017+ )
1018+ res = catalog_create ("pb" , b_code )
1019+ assert "error" not in res , res
1020+ b_h = _hash_of (project )
1021+
1022+ _write_ints (srca , [0 , 1 , 2 , 3 , 4 ]) # edit BOTH sources in place after building
1023+ _write_ints (srcb , [10 , 11 , 12 ])
1024+ cached_result_expr .cache_clear ()
1025+
1026+ df = cached_result_expr (project , b_h ).execute ()
1027+ # Frozen a = {0,1,2} ∪ frozen b = {10,11}. A leaked contextvar would serve the
1028+ # edited {0,1,2,3,4} and/or {10,11,12}.
1029+ assert sorted (df ["x" ].tolist ()) == [0 , 1 , 2 , 10 , 11 ]
1030+
1031+
1032+ def test_cas_child_records_reconstructed_parent_frozen_digest (project , monkeypatch ):
1033+ """#115: building a child records the parent's FROZEN source digest, keeping the
1034+ parent's .cas clone alive for GC.
1035+
1036+ Building a child reconstructs the parent's recipe, re-running the parent's
1037+ from_project. That must record the parent's frozen digest (the bytes actually
1038+ read) into the child's manifest.sources — not a re-digest of the edited live file
1039+ — so gc_cas keeps the frozen clone the child's reconstruction depends on.
1040+ """
1041+ from tallyman_core import data_dir , entry_dir , read_manifest
1042+ from tallyman_xorq import source_identity as si
1043+
1044+ monkeypatch .setenv ("TALLYMAN_PROJECT" , project )
1045+ monkeypatch .setenv ("TALLYMAN_SOURCE_IDENTITY" , "cas" )
1046+ assert si .mode () == "cas"
1047+
1048+ src = data_dir (project ) / "src.parquet"
1049+ _write_ints (src , [0 , 1 , 2 ])
1050+ catalog_create ("pp" , _src_read_code (project , "src.parquet" ))
1051+ p_h = _hash_of (project )
1052+ p_digest = read_manifest (entry_dir (project , p_h )).sources ["src.parquet" ]
1053+
1054+ _write_ints (src , [0 , 1 , 2 , 3 , 4 ]) # edit in place, then build a child off the parent
1055+ cc_code = "from tallyman_xorq.io import from_catalog\n t = from_catalog('pp')\n expr = t.mutate(y=t.x * 2)\n "
1056+ res = catalog_create ("cc" , cc_code )
1057+ assert "error" not in res , res
1058+ c_h = _hash_of (project )
1059+
1060+ child_sources = read_manifest (entry_dir (project , c_h )).sources or {}
1061+ assert child_sources .get ("src.parquet" ) == p_digest
1062+ # Sanity: the frozen digest the child pinned is NOT the edited live digest.
1063+ assert si .digest_for (project , src ) != p_digest
1064+
1065+
1066+ def test_off_built_entry_reads_under_cas_default_without_crash (project , monkeypatch ):
1067+ """#115 graceful degradation: an entry built under mode=off (no recorded sources)
1068+ read under the cas default falls through to a live read, not a crash.
1069+
1070+ The digest-pinned hook is a no-op when manifest.sources is None — there is no
1071+ frozen clone to pin — so reconstruction reads the live source as before. (Per the
1072+ single-user rebuild-always policy, rebuilding such an entry under cas is the path
1073+ to pinning; this only pins the no-crash contract.)
1074+ """
1075+ from tallyman_core import data_dir , entry_dir , read_manifest
1076+ from tallyman_xorq import build_and_persist
1077+ from tallyman_xorq .result_cache import cached_result_expr
1078+
1079+ monkeypatch .setenv ("TALLYMAN_PROJECT" , project )
1080+ src = data_dir (project ) / "src.parquet"
1081+ _write_ints (src , [0 , 1 , 2 ])
1082+
1083+ monkeypatch .setenv ("TALLYMAN_SOURCE_IDENTITY" , "off" )
1084+ h = build_and_persist (project , _src_read_code (project , "src.parquet" )).content_hash
1085+ assert read_manifest (entry_dir (project , h )).sources is None # off records no sources
1086+
1087+ monkeypatch .setenv ("TALLYMAN_SOURCE_IDENTITY" , "cas" ) # flip to the default
1088+ cached_result_expr .cache_clear ()
1089+ df = cached_result_expr (project , h ).execute () # must not raise; reads the live source
1090+ assert sorted (df ["x" ].tolist ()) == [0 , 1 , 2 ]
1091+
1092+
8751093def test_reset_to_clears_result_plan_memo (project , orders_parquet , monkeypatch ):
8761094 """reset_to retires the on-disk compute cache to the target revision's warm
8771095 set; it must also invalidate the in-process ``_resolve_result_plan`` memo,
0 commit comments