@@ -41,6 +41,28 @@ def cache_dir() -> Optional[Path]:
4141 return Path (value ) if value else None
4242
4343
44+ def _canonical_ids (output_node : Node ) -> Dict [int , int ]:
45+ """Map current ``node_id`` -> canonical id, independent of creation order.
46+
47+ Preorder DFS from the output following each node's ORDERED ``inputs``
48+ list; first visit assigns the next canonical number. The raw global
49+ node-id counter is process-cumulative: a warm process that builds the
50+ graph twice (observed: Modal containers reuse one process across
51+ compiles) shifts every id, which would change the fingerprint and
52+ silently miss the cache. Canonical ids depend only on the topology.
53+ """
54+ canon : Dict [int , int ] = {}
55+ stack = [output_node ]
56+ while stack :
57+ n = stack .pop ()
58+ if n .node_id in canon :
59+ continue
60+ canon [n .node_id ] = len (canon )
61+ # Reversed keeps the first input on top of the stack (preorder).
62+ stack .extend (reversed (getattr (n , "inputs" , None ) or []))
63+ return canon
64+
65+
4466def graph_fingerprint (
4567 output_node : Node ,
4668 pos_encoding : PosEncoding ,
@@ -53,21 +75,26 @@ def graph_fingerprint(
5375 cancel_slack : Optional [int ],
5476 policy : Optional [SchedulingPolicy ],
5577) -> str :
56- """Topology + geometry hash.
57-
58- Includes the node ids themselves: a cached assignment is keyed by
59- ``node_id``, which is only meaningful if graph construction replays
60- identically — any change to construction order changes the ids, the
61- fingerprint, and therefore misses (correct by construction).
62- """
78+ """Topology + geometry hash over CANONICAL node ids (see
79+ ``_canonical_ids``) — stable across processes and warm containers; any
80+ change to graph construction still changes the fingerprint and misses
81+ (correct by construction)."""
82+ canon = _canonical_ids (output_node )
6383 graph = GraphAnalyzer (output_node )
64- nodes = sorted (graph .get_all_nodes (), key = lambda n : n .node_id )
84+ nodes = sorted (
85+ (n for n in graph .get_all_nodes () if n .node_id in canon ),
86+ key = lambda n : canon [n .node_id ],
87+ )
6588 topo = [
6689 (
67- n .node_id ,
90+ canon [ n .node_id ] ,
6891 type (n ).__name__ ,
6992 len (n ),
70- tuple (inp .node_id for inp in (getattr (n , "inputs" , None ) or [])),
93+ tuple (
94+ canon [inp .node_id ]
95+ for inp in (getattr (n , "inputs" , None ) or [])
96+ if inp .node_id in canon
97+ ),
7198 )
7299 for n in nodes
73100 ]
@@ -88,30 +115,44 @@ def graph_fingerprint(
88115
89116def load_assignment (
90117 fingerprint : str ,
118+ output_node : Node ,
91119) -> Optional [Tuple [ScheduleAssignment , Dict [str , Any ]]]:
92- """Return (assignment, meta) for a cached fingerprint, or None."""
120+ """Return (assignment, meta) for a cached fingerprint, or None.
121+
122+ Stored keys are canonical ids; they are remapped onto the CURRENT
123+ graph's node ids via the same deterministic traversal."""
93124 base = cache_dir ()
94125 if base is None :
95126 return None
96127 path = base / f"{ fingerprint } .json"
97128 if not path .exists ():
98129 return None
99130 data = json .loads (path .read_text (encoding = "utf-8" ))
100- assignment = ScheduleAssignment (
101- node_to_layer = {int (k ): v for k , v in data ["node_to_layer" ].items ()},
102- node_to_cancel_layer = {
103- int (k ): v for k , v in data ["node_to_cancel_layer" ].items ()
104- },
105- node_to_routing = {int (k ): v for k , v in data ["node_to_routing" ].items ()},
106- n_layers = data ["n_layers" ],
107- )
131+ current_by_canon = {c : nid for nid , c in _canonical_ids (output_node ).items ()}
132+
133+ def _remap (table : Dict [str , Any ]) -> Dict [int , Any ]:
134+ return {current_by_canon [int (k )]: v for k , v in table .items ()}
135+
136+ try :
137+ assignment = ScheduleAssignment (
138+ node_to_layer = _remap (data ["node_to_layer" ]),
139+ node_to_cancel_layer = _remap (data ["node_to_cancel_layer" ]),
140+ node_to_routing = _remap (data ["node_to_routing" ]),
141+ n_layers = data ["n_layers" ],
142+ )
143+ except KeyError :
144+ # Canonical id absent from the current graph: the entry predates a
145+ # construction change the fingerprint failed to capture. Treat as
146+ # a miss rather than replaying a wrong schedule.
147+ return None
108148 return assignment , data .get ("meta" , {})
109149
110150
111151def store_assignment (
112152 fingerprint : str ,
113153 assignment : ScheduleAssignment ,
114154 meta : Dict [str , Any ],
155+ output_node : Node ,
115156) -> bool :
116157 """Persist ``assignment`` unless an equal-or-better entry exists.
117158
@@ -120,14 +161,26 @@ def store_assignment(
120161 base = cache_dir ()
121162 if base is None :
122163 return False
123- existing = load_assignment (fingerprint )
124- if existing is not None and existing [0 ].n_layers <= assignment .n_layers :
125- return False
164+ prior_path = base / f"{ fingerprint } .json"
165+ if prior_path .exists ():
166+ prior = json .loads (prior_path .read_text (encoding = "utf-8" ))
167+ if prior .get ("n_layers" , 1 << 30 ) <= assignment .n_layers :
168+ return False
126169 base .mkdir (parents = True , exist_ok = True )
170+ canon = _canonical_ids (output_node )
171+ if not set (assignment .node_to_layer ) <= set (canon ):
172+ # A scheduled node is unreachable from the output via inputs — it
173+ # cannot be keyed canonically; skip caching rather than store a
174+ # partial schedule.
175+ return False
127176 payload = {
128- "node_to_layer" : assignment .node_to_layer ,
129- "node_to_cancel_layer" : assignment .node_to_cancel_layer ,
130- "node_to_routing" : assignment .node_to_routing ,
177+ "node_to_layer" : {canon [k ]: v for k , v in assignment .node_to_layer .items ()},
178+ "node_to_cancel_layer" : {
179+ canon [k ]: v
180+ for k , v in assignment .node_to_cancel_layer .items ()
181+ if k in canon
182+ },
183+ "node_to_routing" : {canon [k ]: v for k , v in assignment .node_to_routing .items ()},
131184 "n_layers" : assignment .n_layers ,
132185 "meta" : meta ,
133186 }
0 commit comments