OR-1 dataflow CPU sketch
1"""
2Lane-based PE rewrite tests.
3
4Verifies frame-lanes.AC3, frame-lanes.AC4, frame-lanes.AC5, and frame-lanes.AC8:
5- AC3.1: FrameOp.ALLOC_SHARED assigns next free lane from parent frame
6- AC3.2: FrameOp.FREE_LANE removes tag_store entry, clears lane data, keeps frame
7- AC3.3: FrameOp.FREE on shared frame returns lane if frame still in use
8- AC3.4: FrameOp.ALLOC unchanged — allocates fresh frame, assigns lane 0
9- AC3.5: FrameAllocated event gains lane field
10- AC3.6: ALLOC_SHARED with all lanes occupied emits TokenRejected
11- AC4: ALLOC_REMOTE reads fref+2 for data-driven ALLOC_SHARED vs ALLOC
12- AC5.1: FREE_FRAME opcode uses smart FREE behaviour on shared frames
13- AC8.1: Two act_ids sharing a frame have independent matching
14- AC8.2: ALLOC_SHARED with exhausted lanes emits TokenRejected
15- AC8.3: FREE on shared frame preserves other lanes' data
16- AC8.4: ALLOC_REMOTE emits ALLOC_SHARED when fref+2 is non-zero
17- AC8.5: ALLOC_REMOTE emits ALLOC when fref+2 is zero (backwards compatible)
18- AC8.6: Full loop pipelining scenario — two iterations concurrent on different lanes
19"""
20
21import pytest
22import simpy
23
24from cm_inst import (
25 ArithOp, FrameDest, FrameOp, Instruction, Port, TokenKind, OutputStyle,
26 RoutingOp,
27)
28from emu.events import (
29 FrameAllocated, FrameFreed, TokenReceived, TokenRejected, Matched, Emitted,
30)
31from emu.pe import ProcessingElement
32from emu.types import PEConfig
33from tokens import DyadToken, FrameControlToken
34
35
36def inject_and_run(env, pe, token):
37 """Helper: inject token and run simulation."""
38 def _put():
39 yield pe.input_store.put(token)
40 env.process(_put())
41 env.run()
42
43
44class TestAllocShared:
45 """AC3.1: ALLOC_SHARED assigns next free lane from parent frame."""
46
47 def test_alloc_shared_basic(self):
48 """Parent allocates frame, child allocates shared lane."""
49 env = simpy.Environment()
50 events = []
51 config = PEConfig(frame_count=4, lane_count=4, on_event=events.append)
52 pe = ProcessingElement(env=env, pe_id=0, config=config)
53
54 # Parent ALLOC
55 fct_parent = FrameControlToken(
56 target=0, act_id=0, op=FrameOp.ALLOC, payload=0
57 )
58 inject_and_run(env, pe, fct_parent)
59
60 parent_frame_id, parent_lane = pe.tag_store[0]
61 assert parent_lane == 0, "Parent should allocate lane 0"
62
63 # Child ALLOC_SHARED with parent_act_id=0
64 fct_child = FrameControlToken(
65 target=0, act_id=1, op=FrameOp.ALLOC_SHARED, payload=0
66 )
67 inject_and_run(env, pe, fct_child)
68
69 child_frame_id, child_lane = pe.tag_store[1]
70 assert child_frame_id == parent_frame_id, "Child should share parent's frame"
71 assert child_lane == 1, "Child should allocate lane 1"
72 assert child_lane != parent_lane, "Child lane should differ from parent"
73
74 # Verify FrameAllocated event for child
75 frame_allocated = [e for e in events if isinstance(e, FrameAllocated)]
76 assert len(frame_allocated) >= 2, "Should have 2 FrameAllocated events"
77 assert frame_allocated[0].lane == 0, "Parent allocated lane 0"
78 assert frame_allocated[1].lane == 1, "Child allocated lane 1"
79
80 def test_alloc_shared_multiple_lanes(self):
81 """Multiple children allocate different lanes from same parent frame."""
82 env = simpy.Environment()
83 events = []
84 config = PEConfig(frame_count=4, lane_count=4, on_event=events.append)
85 pe = ProcessingElement(env=env, pe_id=0, config=config)
86
87 # Parent ALLOC
88 fct_parent = FrameControlToken(
89 target=0, act_id=0, op=FrameOp.ALLOC, payload=0
90 )
91 inject_and_run(env, pe, fct_parent)
92 parent_frame_id, _parent_lane = pe.tag_store[0]
93
94 # Child 1 ALLOC_SHARED
95 fct_child1 = FrameControlToken(
96 target=0, act_id=1, op=FrameOp.ALLOC_SHARED, payload=0
97 )
98 inject_and_run(env, pe, fct_child1)
99 _child1_frame_id, child1_lane = pe.tag_store[1]
100
101 # Child 2 ALLOC_SHARED
102 fct_child2 = FrameControlToken(
103 target=0, act_id=2, op=FrameOp.ALLOC_SHARED, payload=0
104 )
105 inject_and_run(env, pe, fct_child2)
106 _child2_frame_id, child2_lane = pe.tag_store[2]
107
108 # All should share same frame
109 assert pe.tag_store[0][0] == parent_frame_id
110 assert pe.tag_store[1][0] == parent_frame_id
111 assert pe.tag_store[2][0] == parent_frame_id
112
113 # Lanes should differ: 0, 1, 2
114 assert child1_lane != 0, "Child1 lane should not be 0"
115 assert child2_lane != 0, "Child2 lane should not be 0"
116 assert child1_lane != child2_lane, "Child1 and child2 lanes should differ"
117
118 def test_alloc_shared_invalid_parent(self):
119 """ALLOC_SHARED with non-existent parent emits TokenRejected."""
120 env = simpy.Environment()
121 events = []
122 config = PEConfig(frame_count=4, lane_count=4, on_event=events.append)
123 pe = ProcessingElement(env=env, pe_id=0, config=config)
124
125 # Try ALLOC_SHARED with non-existent parent_act_id=999
126 fct = FrameControlToken(
127 target=0, act_id=0, op=FrameOp.ALLOC_SHARED, payload=999
128 )
129 inject_and_run(env, pe, fct)
130
131 rejected = [e for e in events if isinstance(e, TokenRejected)]
132 assert len(rejected) > 0, "Should have TokenRejected event"
133 assert "not in tag store" in rejected[0].reason, "Reason should mention tag_store"
134
135 # Parent should not be in tag_store
136 assert 999 not in pe.tag_store
137
138 def test_alloc_shared_self_referential_guard(self):
139 """ALLOC_SHARED with act_id already in tag_store emits TokenRejected."""
140 env = simpy.Environment()
141 events = []
142 config = PEConfig(frame_count=4, lane_count=4, on_event=events.append)
143 pe = ProcessingElement(env=env, pe_id=0, config=config)
144
145 # First ALLOC to establish act_id=0 in tag_store
146 fct_alloc = FrameControlToken(
147 target=0, act_id=0, op=FrameOp.ALLOC, payload=0
148 )
149 inject_and_run(env, pe, fct_alloc)
150 assert 0 in pe.tag_store, "act_id=0 should be in tag_store after ALLOC"
151 frame_id_0, lane_0 = pe.tag_store[0]
152
153 # Now try ALLOC_SHARED with act_id=0 and payload=1 (parent_act_id=1)
154 # This should be rejected because act_id=0 already exists
155 fct_alloc_parent = FrameControlToken(
156 target=0, act_id=1, op=FrameOp.ALLOC, payload=0
157 )
158 inject_and_run(env, pe, fct_alloc_parent)
159 assert 1 in pe.tag_store, "act_id=1 should be in tag_store after ALLOC"
160
161 events.clear()
162 fct_shared = FrameControlToken(
163 target=0, act_id=0, op=FrameOp.ALLOC_SHARED, payload=1
164 )
165 inject_and_run(env, pe, fct_shared)
166
167 rejected = [e for e in events if isinstance(e, TokenRejected)]
168 assert len(rejected) > 0, "Should have TokenRejected event"
169 assert "already in tag store" in rejected[0].reason, "Reason should mention already in tag store"
170
171 # Frame and lane should be unchanged
172 assert pe.tag_store[0] == (frame_id_0, lane_0), "act_id=0 state should be unchanged"
173
174
175class TestLaneExhaustion:
176 """AC3.6, AC8.2: Lane exhaustion and TokenRejected."""
177
178 def test_alloc_shared_exhausts_all_lanes(self):
179 """Allocate all lanes, then ALLOC_SHARED fails with TokenRejected."""
180 env = simpy.Environment()
181 events = []
182 config = PEConfig(frame_count=4, lane_count=4, on_event=events.append)
183 pe = ProcessingElement(env=env, pe_id=0, config=config)
184
185 # Parent ALLOC uses lane 0
186 fct_parent = FrameControlToken(
187 target=0, act_id=0, op=FrameOp.ALLOC, payload=0
188 )
189 inject_and_run(env, pe, fct_parent)
190
191 # Allocate lanes 1, 2, 3
192 for i in range(1, 4):
193 fct = FrameControlToken(
194 target=0, act_id=i, op=FrameOp.ALLOC_SHARED, payload=0
195 )
196 inject_and_run(env, pe, fct)
197 assert i in pe.tag_store, f"Child {i} should be allocated"
198
199 # Try to allocate one more (all lanes exhausted)
200 fct_fail = FrameControlToken(
201 target=0, act_id=4, op=FrameOp.ALLOC_SHARED, payload=0
202 )
203 inject_and_run(env, pe, fct_fail)
204
205 rejected = [e for e in events if isinstance(e, TokenRejected)]
206 assert len(rejected) > 0, "Should have TokenRejected event"
207 assert "no free lanes" in rejected[0].reason, "Reason should be 'no free lanes'"
208
209 # act_id=4 should not be in tag_store
210 assert 4 not in pe.tag_store, "Failed allocation should not add to tag_store"
211
212 def test_lane_exhaustion_with_multiple_frames(self):
213 """Lane exhaustion is per-frame; different frames have independent lanes."""
214 env = simpy.Environment()
215 events = []
216 config = PEConfig(frame_count=4, lane_count=4, on_event=events.append)
217 pe = ProcessingElement(env=env, pe_id=0, config=config)
218
219 # Frame 1: Parent 0 allocates lane 0
220 fct1 = FrameControlToken(
221 target=0, act_id=0, op=FrameOp.ALLOC, payload=0
222 )
223 inject_and_run(env, pe, fct1)
224 frame1_id, _lane = pe.tag_store[0]
225
226 # Frame 2: Parent 10 allocates lane 0
227 fct2 = FrameControlToken(
228 target=0, act_id=10, op=FrameOp.ALLOC, payload=0
229 )
230 inject_and_run(env, pe, fct2)
231 frame2_id, _lane = pe.tag_store[10]
232
233 assert frame1_id != frame2_id, "Should allocate different frames"
234
235 # Frame 1: Exhaust all lanes
236 for i in range(1, 4):
237 fct = FrameControlToken(
238 target=0, act_id=i, op=FrameOp.ALLOC_SHARED, payload=0
239 )
240 inject_and_run(env, pe, fct)
241
242 # Frame 2: Can still allocate more lanes (independent)
243 for i in range(11, 14):
244 fct = FrameControlToken(
245 target=0, act_id=i, op=FrameOp.ALLOC_SHARED, payload=10
246 )
247 inject_and_run(env, pe, fct)
248 assert i in pe.tag_store, f"Frame2 child {i} should be allocated"
249
250
251class TestFreeLane:
252 """AC3.2: FREE_LANE clears lane data, keeps frame, returns lane to pool."""
253
254 def test_free_lane_basic(self):
255 """FREE_LANE removes act_id from tag_store, clears lane data, keeps frame."""
256 env = simpy.Environment()
257 events = []
258 config = PEConfig(frame_count=4, lane_count=4, on_event=events.append)
259 pe = ProcessingElement(env=env, pe_id=0, config=config)
260
261 # Parent ALLOC
262 fct_parent = FrameControlToken(
263 target=0, act_id=0, op=FrameOp.ALLOC, payload=0
264 )
265 inject_and_run(env, pe, fct_parent)
266 parent_frame_id, _parent_lane = pe.tag_store[0]
267
268 # Child ALLOC_SHARED
269 fct_child = FrameControlToken(
270 target=0, act_id=1, op=FrameOp.ALLOC_SHARED, payload=0
271 )
272 inject_and_run(env, pe, fct_child)
273 _child_frame_id, child_lane = pe.tag_store[1]
274
275 # FREE_LANE for child
276 fct_free = FrameControlToken(
277 target=0, act_id=1, op=FrameOp.FREE_LANE, payload=0
278 )
279 inject_and_run(env, pe, fct_free)
280
281 # Child should be removed from tag_store
282 assert 1 not in pe.tag_store, "Child should be removed from tag_store"
283
284 # Parent should still be present
285 assert 0 in pe.tag_store, "Parent should still be in tag_store"
286
287 # Frame should NOT be in free_frames (still used by parent)
288 assert parent_frame_id not in pe.free_frames, "Frame should not be free"
289
290 # FrameFreed event should have frame_freed=False
291 frame_freed = [e for e in events if isinstance(e, FrameFreed)]
292 assert len(frame_freed) > 0, "Should have FrameFreed event"
293 assert frame_freed[-1].frame_freed == False, "frame_freed should be False"
294 assert frame_freed[-1].lane == child_lane, "Event should report correct lane"
295
296 def test_free_lane_returns_lane_to_pool(self):
297 """After FREE_LANE, freed lane can be reused by ALLOC_SHARED."""
298 env = simpy.Environment()
299 events = []
300 config = PEConfig(frame_count=4, lane_count=4, on_event=events.append)
301 pe = ProcessingElement(env=env, pe_id=0, config=config)
302
303 # Parent ALLOC
304 fct_parent = FrameControlToken(
305 target=0, act_id=0, op=FrameOp.ALLOC, payload=0
306 )
307 inject_and_run(env, pe, fct_parent)
308 parent_frame_id, _parent_lane = pe.tag_store[0]
309
310 # Child 1 ALLOC_SHARED (lane 1)
311 fct_child1 = FrameControlToken(
312 target=0, act_id=1, op=FrameOp.ALLOC_SHARED, payload=0
313 )
314 inject_and_run(env, pe, fct_child1)
315 _child1_frame_id, child1_lane = pe.tag_store[1]
316 assert child1_lane == 1
317
318 # FREE_LANE child 1
319 fct_free = FrameControlToken(
320 target=0, act_id=1, op=FrameOp.FREE_LANE, payload=0
321 )
322 inject_and_run(env, pe, fct_free)
323
324 # Child 2 ALLOC_SHARED (should get lane 1 again)
325 fct_child2 = FrameControlToken(
326 target=0, act_id=2, op=FrameOp.ALLOC_SHARED, payload=0
327 )
328 inject_and_run(env, pe, fct_child2)
329 _child2_frame_id, child2_lane = pe.tag_store[2]
330
331 # Lane 1 should be reused for child 2
332 assert child2_lane == 1, "Freed lane 1 should be reused"
333
334
335class TestIndependentMatching:
336 """AC8.1: Two act_ids sharing a frame have independent matching."""
337
338 def test_independent_matching_same_offset(self):
339 """L operand for act_id 0 does not interfere with L for act_id 1."""
340 env = simpy.Environment()
341 events = []
342 config = PEConfig(
343 frame_count=4, lane_count=4, matchable_offsets=4, on_event=events.append
344 )
345 pe = ProcessingElement(env=env, pe_id=0, config=config)
346
347 # Parent ALLOC
348 fct_parent = FrameControlToken(
349 target=0, act_id=0, op=FrameOp.ALLOC, payload=0
350 )
351 inject_and_run(env, pe, fct_parent)
352 parent_frame_id, _parent_lane = pe.tag_store[0]
353
354 # Child ALLOC_SHARED
355 fct_child = FrameControlToken(
356 target=0, act_id=1, op=FrameOp.ALLOC_SHARED, payload=0
357 )
358 inject_and_run(env, pe, fct_child)
359 _child_frame_id, child_lane = pe.tag_store[1]
360
361 # Install dyadic instruction at offset 0
362 inst = Instruction(
363 opcode=ArithOp.ADD,
364 output=OutputStyle.SINK,
365 has_const=False,
366 dest_count=0,
367 wide=False,
368 fref=0,
369 )
370 pe.iram[0] = inst
371
372 # Send L operand for act_id=0
373 tok_l_0 = DyadToken(
374 target=0, offset=0, act_id=0, data=5, port=Port.L
375 )
376 inject_and_run(env, pe, tok_l_0)
377
378 # Should have 1 TokenReceived, 0 Matched (waiting for R)
379 matched = [e for e in events if isinstance(e, Matched)]
380 assert len(matched) == 0, "Should not match yet (waiting for R)"
381
382 # Send L operand for act_id=1 at same offset
383 tok_l_1 = DyadToken(
384 target=0, offset=0, act_id=1, data=7, port=Port.L
385 )
386 inject_and_run(env, pe, tok_l_1)
387
388 # Should still have 0 Matched (both waiting for R)
389 matched = [e for e in events if isinstance(e, Matched)]
390 assert len(matched) == 0, "Both should be waiting for R"
391
392 # Send R for act_id=0
393 tok_r_0 = DyadToken(
394 target=0, offset=0, act_id=0, data=3, port=Port.R
395 )
396 inject_and_run(env, pe, tok_r_0)
397
398 # Should now have 1 Matched for act_id=0
399 matched = [e for e in events if isinstance(e, Matched)]
400 assert len(matched) == 1, "Should have 1 match for act_id=0"
401 assert matched[0].act_id == 0, "Match should be for act_id=0"
402 assert matched[0].left == 5, "Left should be 5"
403 assert matched[0].right == 3, "Right should be 3"
404
405 # Send R for act_id=1
406 tok_r_1 = DyadToken(
407 target=0, offset=0, act_id=1, data=2, port=Port.R
408 )
409 inject_and_run(env, pe, tok_r_1)
410
411 # Should now have 2 Matched
412 matched = [e for e in events if isinstance(e, Matched)]
413 assert len(matched) == 2, "Should have 2 matches total"
414 m1 = [m for m in matched if m.act_id == 1][0]
415 assert m1.left == 7, "act_id=1 left should be 7"
416 assert m1.right == 2, "act_id=1 right should be 2"
417
418 def test_independent_matching_different_offsets(self):
419 """Different offsets per lane maintain independence."""
420 env = simpy.Environment()
421 events = []
422 config = PEConfig(
423 frame_count=4, lane_count=4, matchable_offsets=4, on_event=events.append
424 )
425 pe = ProcessingElement(env=env, pe_id=0, config=config)
426
427 # Parent ALLOC
428 fct_parent = FrameControlToken(
429 target=0, act_id=0, op=FrameOp.ALLOC, payload=0
430 )
431 inject_and_run(env, pe, fct_parent)
432
433 # Child ALLOC_SHARED
434 fct_child = FrameControlToken(
435 target=0, act_id=1, op=FrameOp.ALLOC_SHARED, payload=0
436 )
437 inject_and_run(env, pe, fct_child)
438
439 # Install dyadic instructions at offsets 0 and 1
440 inst0 = Instruction(
441 opcode=ArithOp.ADD, output=OutputStyle.SINK,
442 has_const=False, dest_count=0, wide=False, fref=0
443 )
444 inst1 = Instruction(
445 opcode=ArithOp.SUB, output=OutputStyle.SINK,
446 has_const=False, dest_count=0, wide=False, fref=0
447 )
448 pe.iram[0] = inst0
449 pe.iram[1] = inst1
450
451 # Send L for act_id=0 at offset 0
452 tok_l_0_off0 = DyadToken(
453 target=0, offset=0, act_id=0, data=10, port=Port.L
454 )
455 inject_and_run(env, pe, tok_l_0_off0)
456
457 # Send L for act_id=1 at offset 1
458 tok_l_1_off1 = DyadToken(
459 target=0, offset=1, act_id=1, data=20, port=Port.L
460 )
461 inject_and_run(env, pe, tok_l_1_off1)
462
463 # Neither should match yet
464 matched = [e for e in events if isinstance(e, Matched)]
465 assert len(matched) == 0, "No matches yet"
466
467 # Send R for act_id=0 at offset 0
468 tok_r_0_off0 = DyadToken(
469 target=0, offset=0, act_id=0, data=5, port=Port.R
470 )
471 inject_and_run(env, pe, tok_r_0_off0)
472
473 # Should match for offset 0
474 matched = [e for e in events if isinstance(e, Matched)]
475 assert len(matched) == 1, "Should have 1 match"
476 assert matched[0].offset == 0, "Match should be at offset 0"
477
478 # Send R for act_id=1 at offset 1
479 tok_r_1_off1 = DyadToken(
480 target=0, offset=1, act_id=1, data=15, port=Port.R
481 )
482 inject_and_run(env, pe, tok_r_1_off1)
483
484 # Should match for offset 1
485 matched = [e for e in events if isinstance(e, Matched)]
486 assert len(matched) == 2, "Should have 2 matches"
487 m1 = [m for m in matched if m.offset == 1][0]
488 assert m1.act_id == 1, "Offset 1 match should be act_id=1"
489
490
491class TestSmartFree:
492 """AC3.3, AC8.3: Smart FREE on shared frames preserves data and manages lanes."""
493
494 def test_free_on_shared_frame_preserves_other_lanes(self):
495 """FREE on act_id=0 when act_id=1 uses frame; lane 1 data preserved."""
496 env = simpy.Environment()
497 events = []
498 config = PEConfig(
499 frame_count=4, lane_count=4, matchable_offsets=4, on_event=events.append
500 )
501 pe = ProcessingElement(env=env, pe_id=0, config=config)
502
503 # Parent ALLOC
504 fct_parent = FrameControlToken(
505 target=0, act_id=0, op=FrameOp.ALLOC, payload=0
506 )
507 inject_and_run(env, pe, fct_parent)
508 parent_frame_id, _parent_lane = pe.tag_store[0]
509
510 # Child ALLOC_SHARED
511 fct_child = FrameControlToken(
512 target=0, act_id=1, op=FrameOp.ALLOC_SHARED, payload=0
513 )
514 inject_and_run(env, pe, fct_child)
515 _child_frame_id, child_lane = pe.tag_store[1]
516
517 # Install instruction
518 inst = Instruction(
519 opcode=ArithOp.ADD, output=OutputStyle.SINK,
520 has_const=False, dest_count=0, wide=False, fref=0
521 )
522 pe.iram[0] = inst
523
524 # Store L operand on child's lane
525 tok_l_1 = DyadToken(
526 target=0, offset=0, act_id=1, data=7, port=Port.L
527 )
528 inject_and_run(env, pe, tok_l_1)
529
530 # Verify child's match slot has data
531 frame_id, lane = pe.tag_store[1]
532 assert pe.match_data[frame_id][0][lane] == 7, "Child lane should have L operand"
533 assert pe.presence[frame_id][0][lane] == True, "Child presence should be set"
534
535 # FREE parent
536 fct_free_parent = FrameControlToken(
537 target=0, act_id=0, op=FrameOp.FREE, payload=0
538 )
539 inject_and_run(env, pe, fct_free_parent)
540
541 # Parent should be removed, child should still be present
542 assert 0 not in pe.tag_store, "Parent should be removed"
543 assert 1 in pe.tag_store, "Child should still be present"
544
545 # Frame should NOT be in free_frames
546 assert parent_frame_id not in pe.free_frames, "Frame should not be free"
547
548 # Child's match data should be preserved
549 assert pe.match_data[frame_id][0][lane] == 7, "Child data should be preserved"
550 assert pe.presence[frame_id][0][lane] == True, "Child presence should be preserved"
551
552 # FrameFreed event should have frame_freed=False
553 frame_freed = [e for e in events if isinstance(e, FrameFreed)]
554 assert any(e.frame_freed == False for e in frame_freed), "Should have frame_freed=False"
555
556 def test_free_last_lane_returns_frame(self):
557 """FREE on last act_id using frame returns frame to free_frames."""
558 env = simpy.Environment()
559 events = []
560 config = PEConfig(frame_count=4, lane_count=4, on_event=events.append)
561 pe = ProcessingElement(env=env, pe_id=0, config=config)
562
563 # Parent ALLOC
564 fct_parent = FrameControlToken(
565 target=0, act_id=0, op=FrameOp.ALLOC, payload=0
566 )
567 inject_and_run(env, pe, fct_parent)
568 parent_frame_id, _parent_lane = pe.tag_store[0]
569
570 # Child ALLOC_SHARED
571 fct_child = FrameControlToken(
572 target=0, act_id=1, op=FrameOp.ALLOC_SHARED, payload=0
573 )
574 inject_and_run(env, pe, fct_child)
575
576 # FREE child
577 fct_free_child = FrameControlToken(
578 target=0, act_id=1, op=FrameOp.FREE_LANE, payload=0
579 )
580 inject_and_run(env, pe, fct_free_child)
581
582 # Frame should still not be free (parent still using it)
583 assert parent_frame_id not in pe.free_frames, "Frame should not be free yet"
584
585 # FREE parent
586 fct_free_parent = FrameControlToken(
587 target=0, act_id=0, op=FrameOp.FREE, payload=0
588 )
589 inject_and_run(env, pe, fct_free_parent)
590
591 # Now frame should be free
592 assert parent_frame_id in pe.free_frames, "Frame should be free"
593
594 # tag_store should be empty
595 assert len(pe.tag_store) == 0, "tag_store should be empty"
596
597 # lane_free entry should be cleaned up
598 assert parent_frame_id not in pe.lane_free, "lane_free entry should be cleaned"
599
600 # FrameFreed event should have frame_freed=True
601 frame_freed = [e for e in events if isinstance(e, FrameFreed)]
602 assert any(e.frame_freed == True for e in frame_freed), "Should have frame_freed=True"
603
604 def test_alloc_unchanged_allocates_fresh_frame(self):
605 """Regular ALLOC still works: allocates fresh frame, lane 0."""
606 env = simpy.Environment()
607 events = []
608 config = PEConfig(frame_count=4, lane_count=4, on_event=events.append)
609 pe = ProcessingElement(env=env, pe_id=0, config=config)
610
611 # First ALLOC
612 fct1 = FrameControlToken(
613 target=0, act_id=0, op=FrameOp.ALLOC, payload=0
614 )
615 inject_and_run(env, pe, fct1)
616 frame_id_0, lane_0 = pe.tag_store[0]
617 assert lane_0 == 0, "First ALLOC should assign lane 0"
618
619 # Second ALLOC (different frame)
620 fct2 = FrameControlToken(
621 target=0, act_id=10, op=FrameOp.ALLOC, payload=0
622 )
623 inject_and_run(env, pe, fct2)
624 frame_id_10, lane_10 = pe.tag_store[10]
625 assert lane_10 == 0, "Second ALLOC should assign lane 0"
626
627 # Frames should be different
628 assert frame_id_0 != frame_id_10, "Different ALLOC should get different frames"
629
630 def test_data_preservation_across_free_lanes(self):
631 """Match data on one lane not affected by FREE of another lane."""
632 env = simpy.Environment()
633 events = []
634 config = PEConfig(
635 frame_count=4, lane_count=4, matchable_offsets=4, on_event=events.append
636 )
637 pe = ProcessingElement(env=env, pe_id=0, config=config)
638
639 # Parent ALLOC
640 fct_parent = FrameControlToken(
641 target=0, act_id=0, op=FrameOp.ALLOC, payload=0
642 )
643 inject_and_run(env, pe, fct_parent)
644 frame_id, _parent_lane = pe.tag_store[0]
645
646 # Child 1 ALLOC_SHARED
647 fct_child1 = FrameControlToken(
648 target=0, act_id=1, op=FrameOp.ALLOC_SHARED, payload=0
649 )
650 inject_and_run(env, pe, fct_child1)
651 _frame_id_1, lane_1 = pe.tag_store[1]
652
653 # Child 2 ALLOC_SHARED
654 fct_child2 = FrameControlToken(
655 target=0, act_id=2, op=FrameOp.ALLOC_SHARED, payload=0
656 )
657 inject_and_run(env, pe, fct_child2)
658 _frame_id_2, lane_2 = pe.tag_store[2]
659
660 # Install instruction
661 inst = Instruction(
662 opcode=ArithOp.ADD, output=OutputStyle.SINK,
663 has_const=False, dest_count=0, wide=False, fref=0
664 )
665 pe.iram[0] = inst
666
667 # Store L operand on lane 1
668 tok_l_1 = DyadToken(
669 target=0, offset=0, act_id=1, data=7, port=Port.L
670 )
671 inject_and_run(env, pe, tok_l_1)
672
673 # Store L operand on lane 2
674 tok_l_2 = DyadToken(
675 target=0, offset=0, act_id=2, data=11, port=Port.L
676 )
677 inject_and_run(env, pe, tok_l_2)
678
679 # FREE lane 1
680 fct_free_1 = FrameControlToken(
681 target=0, act_id=1, op=FrameOp.FREE_LANE, payload=0
682 )
683 inject_and_run(env, pe, fct_free_1)
684
685 # Lane 2's data should be untouched
686 assert pe.match_data[frame_id][0][lane_2] == 11, "Lane 2 data should be preserved"
687 assert pe.presence[frame_id][0][lane_2] == True, "Lane 2 presence should be preserved"
688
689 # Lane 1 should be cleared
690 assert pe.match_data[frame_id][0][lane_1] is None, "Lane 1 data should be cleared"
691 assert pe.presence[frame_id][0][lane_1] == False, "Lane 1 presence should be cleared"
692
693
694class TestAllocRemoteDataDriven:
695 """AC8.4, AC8.5: ALLOC_REMOTE reads fref+2 for data-driven ALLOC_SHARED vs ALLOC."""
696
697 def test_alloc_remote_emits_alloc_shared_when_parent_nonzero(self):
698 """AC8.4: ALLOC_REMOTE emits ALLOC_SHARED when fref+2 is non-zero."""
699 env = simpy.Environment()
700 events = []
701 output_store = simpy.Store(env)
702
703 # PE0: source of ALLOC_REMOTE
704 config0 = PEConfig(frame_count=4, lane_count=4, on_event=events.append)
705 pe0 = ProcessingElement(env=env, pe_id=0, config=config0)
706 pe0.route_table[1] = output_store # Capture emitted token
707
708 # Allocate a frame for act_id=0 on PE0
709 fct_parent = FrameControlToken(
710 target=0, act_id=0, op=FrameOp.ALLOC, payload=0
711 )
712 inject_and_run(env, pe0, fct_parent)
713 frame_id, _lane = pe0.tag_store[0]
714
715 # Set up ALLOC_REMOTE instruction with fref pointing to frame constants
716 # fref+0: target PE=1, fref+1: target act_id=5, fref+2: parent act_id=3
717 inst = Instruction(
718 opcode=RoutingOp.ALLOC_REMOTE,
719 output=OutputStyle.SINK, # Not used for ALLOC_REMOTE
720 has_const=False,
721 dest_count=0,
722 wide=False,
723 fref=10,
724 )
725 pe0.iram[0] = inst
726
727 # Load frame slots with constants
728 pe0.frames[frame_id][10] = 1 # target PE
729 pe0.frames[frame_id][11] = 5 # target act_id
730 pe0.frames[frame_id][12] = 3 # parent act_id (non-zero = ALLOC_SHARED)
731
732 # Send MonadToken to trigger ALLOC_REMOTE
733 tok = DyadToken(
734 target=0, offset=0, act_id=0, data=0, port=Port.L
735 )
736 inject_and_run(env, pe0, tok)
737
738 # Verify FrameControlToken was emitted with ALLOC_SHARED
739 assert len(output_store.items) > 0, "Should have emitted a token"
740 emitted = output_store.items[0]
741 assert isinstance(emitted, FrameControlToken), "Should emit FrameControlToken"
742 assert emitted.op == FrameOp.ALLOC_SHARED, "Should emit ALLOC_SHARED"
743 assert emitted.payload == 3, "Payload should be parent act_id=3"
744 assert emitted.target == 1, "Should target PE 1"
745 assert emitted.act_id == 5, "Should target act_id 5"
746
747 def test_alloc_remote_emits_alloc_when_parent_zero(self):
748 """AC8.5: ALLOC_REMOTE emits ALLOC when fref+2 is zero (backwards compatible)."""
749 env = simpy.Environment()
750 events = []
751 output_store = simpy.Store(env)
752
753 # PE0: source of ALLOC_REMOTE
754 config0 = PEConfig(frame_count=4, lane_count=4, on_event=events.append)
755 pe0 = ProcessingElement(env=env, pe_id=0, config=config0)
756 pe0.route_table[1] = output_store # Capture emitted token
757
758 # Allocate a frame for act_id=0 on PE0
759 fct_parent = FrameControlToken(
760 target=0, act_id=0, op=FrameOp.ALLOC, payload=0
761 )
762 inject_and_run(env, pe0, fct_parent)
763 frame_id, _lane = pe0.tag_store[0]
764
765 # Set up ALLOC_REMOTE instruction
766 # fref+0: target PE=1, fref+1: target act_id=5, fref+2: parent act_id=0
767 inst = Instruction(
768 opcode=RoutingOp.ALLOC_REMOTE,
769 output=OutputStyle.SINK,
770 has_const=False,
771 dest_count=0,
772 wide=False,
773 fref=10,
774 )
775 pe0.iram[0] = inst
776
777 # Load frame slots with constants
778 pe0.frames[frame_id][10] = 1 # target PE
779 pe0.frames[frame_id][11] = 5 # target act_id
780 pe0.frames[frame_id][12] = 0 # parent act_id (zero = ALLOC)
781
782 # Send MonadToken to trigger ALLOC_REMOTE
783 tok = DyadToken(
784 target=0, offset=0, act_id=0, data=0, port=Port.L
785 )
786 inject_and_run(env, pe0, tok)
787
788 # Verify FrameControlToken was emitted with ALLOC (not ALLOC_SHARED)
789 assert len(output_store.items) > 0, "Should have emitted a token"
790 emitted = output_store.items[0]
791 assert isinstance(emitted, FrameControlToken), "Should emit FrameControlToken"
792 assert emitted.op == FrameOp.ALLOC, "Should emit ALLOC"
793 assert emitted.payload == 0, "Payload should be 0 for ALLOC"
794 assert emitted.target == 1, "Should target PE 1"
795 assert emitted.act_id == 5, "Should target act_id 5"
796
797 def test_alloc_remote_fref_plus_2_missing_defaults_to_zero(self):
798 """ALLOC_REMOTE gracefully handles fref+2 outside frame bounds (defaults to 0)."""
799 env = simpy.Environment()
800 events = []
801 output_store = simpy.Store(env)
802
803 # PE0: source of ALLOC_REMOTE
804 config0 = PEConfig(frame_count=4, lane_count=4, on_event=events.append)
805 pe0 = ProcessingElement(env=env, pe_id=0, config=config0)
806 pe0.route_table[1] = output_store
807
808 # Allocate frame
809 fct_parent = FrameControlToken(
810 target=0, act_id=0, op=FrameOp.ALLOC, payload=0
811 )
812 inject_and_run(env, pe0, fct_parent)
813 frame_id, _lane = pe0.tag_store[0]
814
815 # Set up ALLOC_REMOTE with fref pointing near end of frame
816 inst = Instruction(
817 opcode=RoutingOp.ALLOC_REMOTE,
818 output=OutputStyle.SINK,
819 has_const=False,
820 dest_count=0,
821 wide=False,
822 fref=62, # frame_slots defaults to 64, so fref+2=64 is outside
823 )
824 pe0.iram[0] = inst
825
826 # Load only fref+0 and fref+1 (fref+2 is beyond frame bounds)
827 pe0.frames[frame_id][62] = 1
828 pe0.frames[frame_id][63] = 7
829
830 # Send MonadToken
831 tok = DyadToken(
832 target=0, offset=0, act_id=0, data=0, port=Port.L
833 )
834 inject_and_run(env, pe0, tok)
835
836 # Should emit ALLOC (not ALLOC_SHARED) because fref+2 is missing/falsy
837 assert len(output_store.items) > 0, "Should have emitted a token"
838 emitted = output_store.items[0]
839 assert emitted.op == FrameOp.ALLOC, "Should emit ALLOC when fref+2 is missing"
840
841
842class TestFreeFrameOpcode:
843 """AC5.1: FREE_FRAME opcode uses smart FREE behaviour on shared frames."""
844
845 def test_free_frame_opcode_shared_frame_partial_free(self):
846 """FREE_FRAME smart free: partial frame free when other lanes remain."""
847 env = simpy.Environment()
848 events = []
849 config = PEConfig(frame_count=4, lane_count=4, on_event=events.append)
850 pe = ProcessingElement(env=env, pe_id=0, config=config)
851
852 # Pre-allocate frame with two act_ids on different lanes
853 # This simulates ALLOC for act_id=0 and ALLOC_SHARED for act_id=1
854 frame_id = 0
855 pe.frames[frame_id] = [None] * pe.frame_slots
856 pe.tag_store[0] = (frame_id, 0) # act_id=0 on lane 0
857 pe.tag_store[1] = (frame_id, 1) # act_id=1 on lane 1
858 pe.lane_free[frame_id] = {2, 3} # Lanes 2 and 3 are free
859 # Remove frame_id from free_frames (it's in use)
860 if frame_id in pe.free_frames:
861 pe.free_frames.remove(frame_id)
862
863 # Install FREE_FRAME instruction
864 inst = Instruction(
865 opcode=RoutingOp.FREE_FRAME,
866 output=OutputStyle.SINK,
867 has_const=False,
868 dest_count=0,
869 wide=False,
870 fref=0,
871 )
872 pe.iram[0] = inst
873
874 # Send MonadToken for act_id=0 to trigger FREE_FRAME
875 tok = DyadToken(
876 target=0, offset=0, act_id=0, data=0, port=Port.L
877 )
878 inject_and_run(env, pe, tok)
879
880 # Verify act_id=0 is removed from tag_store
881 assert 0 not in pe.tag_store, "act_id=0 should be removed from tag_store"
882
883 # Verify act_id=1 is still in tag_store
884 assert 1 in pe.tag_store, "act_id=1 should still be in tag_store"
885
886 # Verify frame is NOT returned to free_frames (still in use by act_id=1)
887 assert frame_id not in pe.free_frames, "Frame should not be in free_frames"
888
889 # Verify FrameFreed event has frame_freed=False
890 frame_freed = [e for e in events if isinstance(e, FrameFreed)]
891 assert any(e.frame_freed == False for e in frame_freed), \
892 "Should have FrameFreed event with frame_freed=False"
893 last_frame_freed = [e for e in frame_freed if e.act_id == 0][-1]
894 assert last_frame_freed.frame_freed == False, "Frame should not be marked as freed"
895
896 def test_free_frame_opcode_shared_frame_full_free(self):
897 """FREE_FRAME smart free: full frame free when last lane is freed."""
898 env = simpy.Environment()
899 events = []
900 config = PEConfig(frame_count=4, lane_count=4, on_event=events.append)
901 pe = ProcessingElement(env=env, pe_id=0, config=config)
902
903 # Pre-allocate frame with two act_ids
904 frame_id = 0
905 pe.frames[frame_id] = [None] * pe.frame_slots
906 pe.tag_store[0] = (frame_id, 0) # act_id=0 on lane 0
907 pe.tag_store[1] = (frame_id, 1) # act_id=1 on lane 1
908 pe.lane_free[frame_id] = {2, 3}
909 if frame_id in pe.free_frames:
910 pe.free_frames.remove(frame_id)
911
912 # Install FREE_FRAME instruction
913 inst = Instruction(
914 opcode=RoutingOp.FREE_FRAME,
915 output=OutputStyle.SINK,
916 has_const=False,
917 dest_count=0,
918 wide=False,
919 fref=0,
920 )
921 pe.iram[0] = inst
922
923 # First: free act_id=0
924 tok0 = DyadToken(
925 target=0, offset=0, act_id=0, data=0, port=Port.L
926 )
927 inject_and_run(env, pe, tok0)
928
929 # Verify frame still not free
930 assert frame_id not in pe.free_frames, "Frame should not be free after first FREE_FRAME"
931 assert 1 in pe.tag_store, "act_id=1 should still be present"
932
933 # Second: free act_id=1 (last lane on frame)
934 tok1 = DyadToken(
935 target=0, offset=0, act_id=1, data=0, port=Port.L
936 )
937 inject_and_run(env, pe, tok1)
938
939 # Verify frame is now freed
940 assert frame_id in pe.free_frames, "Frame should be in free_frames after last FREE_FRAME"
941 assert 1 not in pe.tag_store, "act_id=1 should be removed from tag_store"
942
943 # Verify tag_store is empty
944 assert len(pe.tag_store) == 0, "tag_store should be empty"
945
946 # Verify lane_free is cleaned up
947 assert frame_id not in pe.lane_free, "lane_free entry should be deleted"
948
949 # Verify FrameFreed event has frame_freed=True
950 frame_freed = [e for e in events if isinstance(e, FrameFreed)]
951 last_frame_freed = [e for e in frame_freed if e.act_id == 1][-1]
952 assert last_frame_freed.frame_freed == True, \
953 "Last FREE_FRAME should emit FrameFreed with frame_freed=True"
954
955
956class TestLoopPipelining:
957 """AC8.6: Full loop pipelining integration test with multiple lanes."""
958
959 def test_full_loop_pipelining_scenario(self):
960 """
961 Complete loop pipelining lifecycle: two iterations of a dyadic instruction
962 running concurrently on different lanes, both producing correct results.
963
964 Simulates:
965 1. ALLOC(act_id=0) → frame, lane 0
966 2. Setup: write destination to frame
967 3. Iteration 1: inject L and R DyadTokens for act_id=0
968 4. ALLOC_SHARED(act_id=1, parent=0) → same frame, lane 1
969 5. Iteration 2: inject L and R DyadTokens for act_id=1
970 6. Both iterations match independently, both produce correct results
971 7. FREE(act_id=0) → lane 0 freed, frame stays
972 8. FREE(act_id=1) → last lane, frame returned to free list
973 """
974 env = simpy.Environment()
975 events = []
976 config = PEConfig(
977 frame_count=4, lane_count=4, matchable_offsets=4,
978 on_event=events.append
979 )
980 pe = ProcessingElement(env=env, pe_id=0, config=config)
981
982 # 1. ALLOC(act_id=0) → frame, lane 0
983 fct_alloc_0 = FrameControlToken(
984 target=0, act_id=0, op=FrameOp.ALLOC, payload=0
985 )
986 inject_and_run(env, pe, fct_alloc_0)
987
988 # Verify act_id=0 is allocated
989 assert 0 in pe.tag_store, "act_id=0 should be in tag_store"
990 frame_id, lane_0 = pe.tag_store[0]
991 assert lane_0 == 0, "First ALLOC should assign lane 0"
992
993 # Verify FrameAllocated event for iteration 1
994 frame_allocated = [e for e in events if isinstance(e, FrameAllocated)]
995 assert len(frame_allocated) >= 1, "Should have FrameAllocated event"
996 assert frame_allocated[0].frame_id == frame_id, "Event should report correct frame_id"
997 assert frame_allocated[0].lane == 0, "Event should report lane 0"
998
999 # 2. Setup: write destination to frame at slot 8
1000 dest = FrameDest(
1001 target_pe=1, offset=0, act_id=0, port=Port.L,
1002 token_kind=TokenKind.MONADIC
1003 )
1004 pe.frames[frame_id][8] = dest
1005
1006 # Set up route to capture output
1007 pe.route_table[1] = simpy.Store(env)
1008
1009 # 3. Install ADD instruction at IRAM offset 0
1010 inst = Instruction(
1011 opcode=ArithOp.ADD,
1012 output=OutputStyle.INHERIT,
1013 has_const=False,
1014 dest_count=1,
1015 wide=False,
1016 fref=8,
1017 )
1018 pe.iram[0] = inst
1019
1020 # 4. ALLOC_SHARED(act_id=1, parent=0) → same frame, lane 1
1021 fct_alloc_shared = FrameControlToken(
1022 target=0, act_id=1, op=FrameOp.ALLOC_SHARED, payload=0
1023 )
1024 inject_and_run(env, pe, fct_alloc_shared)
1025
1026 # Verify act_id=1 is allocated on same frame, different lane
1027 assert 1 in pe.tag_store, "act_id=1 should be in tag_store"
1028 frame_id_1, lane_1 = pe.tag_store[1]
1029 assert frame_id_1 == frame_id, "Both should share same frame"
1030 assert lane_1 == 1, "Second allocation should assign lane 1"
1031 assert lane_1 != lane_0, "Lanes should be different"
1032
1033 # Verify FrameAllocated event for iteration 2
1034 frame_allocated = [e for e in events if isinstance(e, FrameAllocated)]
1035 assert len(frame_allocated) >= 2, "Should have 2 FrameAllocated events"
1036 assert frame_allocated[1].frame_id == frame_id, "Event should report correct frame_id"
1037 assert frame_allocated[1].lane == 1, "Event should report lane 1"
1038
1039 # 5. Inject iteration 1 operands (act_id=0, lane 0)
1040 tok_l_0 = DyadToken(
1041 target=0, offset=0, act_id=0, data=100, port=Port.L
1042 )
1043 inject_and_run(env, pe, tok_l_0)
1044
1045 tok_r_0 = DyadToken(
1046 target=0, offset=0, act_id=0, data=200, port=Port.R
1047 )
1048 inject_and_run(env, pe, tok_r_0)
1049
1050 # Verify Matched event for iteration 1
1051 matched = [e for e in events if isinstance(e, Matched)]
1052 assert len(matched) >= 1, "Should have Matched event for iteration 1"
1053 match_0 = [m for m in matched if m.act_id == 0][-1]
1054 assert match_0.left == 100, "Iteration 1 left operand should be 100"
1055 assert match_0.right == 200, "Iteration 1 right operand should be 200"
1056 assert match_0.offset == 0, "Iteration 1 offset should be 0"
1057
1058 # Verify output token with correct data (100+200=300)
1059 emitted = [e for e in events if isinstance(e, Emitted)]
1060 assert len(emitted) >= 1, "Should have Emitted event for iteration 1"
1061 out_tok_0 = emitted[-1].token
1062 assert out_tok_0.data == 300, "Iteration 1 output should be 300 (100+200)"
1063 assert out_tok_0.target == 1, "Output should route to target_pe=1"
1064
1065 # 6. Inject iteration 2 operands (act_id=1, lane 1)
1066 tok_l_1 = DyadToken(
1067 target=0, offset=0, act_id=1, data=1000, port=Port.L
1068 )
1069 inject_and_run(env, pe, tok_l_1)
1070
1071 tok_r_1 = DyadToken(
1072 target=0, offset=0, act_id=1, data=2000, port=Port.R
1073 )
1074 inject_and_run(env, pe, tok_r_1)
1075
1076 # Verify Matched event for iteration 2
1077 matched = [e for e in events if isinstance(e, Matched)]
1078 assert len(matched) >= 2, "Should have Matched events for both iterations"
1079 match_1 = [m for m in matched if m.act_id == 1][-1]
1080 assert match_1.left == 1000, "Iteration 2 left operand should be 1000"
1081 assert match_1.right == 2000, "Iteration 2 right operand should be 2000"
1082 assert match_1.offset == 0, "Iteration 2 offset should be 0"
1083
1084 # Verify output token with correct data (1000+2000=3000)
1085 emitted = [e for e in events if isinstance(e, Emitted)]
1086 assert len(emitted) >= 2, "Should have Emitted events for both iterations"
1087 out_tok_1 = emitted[-1].token
1088 assert out_tok_1.data == 3000, "Iteration 2 output should be 3000 (1000+2000)"
1089 assert out_tok_1.target == 1, "Output should route to target_pe=1"
1090
1091 # Interleaved verification: confirm independent lanes
1092 matches_by_id = {}
1093 for m in matched:
1094 if m.act_id not in matches_by_id:
1095 matches_by_id[m.act_id] = []
1096 matches_by_id[m.act_id].append(m)
1097
1098 assert 0 in matches_by_id, "Should have match for iteration 1 (act_id=0)"
1099 assert 1 in matches_by_id, "Should have match for iteration 2 (act_id=1)"
1100 assert matches_by_id[0][-1].left == 100, "Iteration 1 left should be 100"
1101 assert matches_by_id[1][-1].left == 1000, "Iteration 2 left should be 1000"
1102
1103 # 7. FREE(act_id=0) → lane 0 freed, frame stays
1104 fct_free_0 = FrameControlToken(
1105 target=0, act_id=0, op=FrameOp.FREE, payload=0
1106 )
1107 inject_and_run(env, pe, fct_free_0)
1108
1109 # Verify act_id=0 removed, act_id=1 still present
1110 assert 0 not in pe.tag_store, "act_id=0 should be removed from tag_store"
1111 assert 1 in pe.tag_store, "act_id=1 should still be in tag_store"
1112
1113 # Verify frame not returned (still used by act_id=1)
1114 assert frame_id not in pe.free_frames, "Frame should not be in free_frames"
1115
1116 # Verify FrameFreed event with frame_freed=False
1117 frame_freed = [e for e in events if isinstance(e, FrameFreed)]
1118 freed_0 = [f for f in frame_freed if f.act_id == 0][-1]
1119 assert freed_0.frame_freed == False, "frame_freed should be False (not last lane)"
1120 assert freed_0.lane == lane_0, "Event should report lane 0"
1121
1122 # 8. FREE(act_id=1) → last lane, frame returned to free list
1123 fct_free_1 = FrameControlToken(
1124 target=0, act_id=1, op=FrameOp.FREE, payload=0
1125 )
1126 inject_and_run(env, pe, fct_free_1)
1127
1128 # Verify act_id=1 removed from tag_store
1129 assert 1 not in pe.tag_store, "act_id=1 should be removed from tag_store"
1130
1131 # Verify tag_store is now empty
1132 assert len(pe.tag_store) == 0, "tag_store should be empty"
1133
1134 # Verify frame returned to free_frames
1135 assert frame_id in pe.free_frames, "Frame should be in free_frames"
1136
1137 # Verify lane_free entry cleaned up
1138 assert frame_id not in pe.lane_free, "lane_free entry should be deleted"
1139
1140 # Verify FrameFreed event with frame_freed=True
1141 frame_freed = [e for e in events if isinstance(e, FrameFreed)]
1142 freed_1 = [f for f in frame_freed if f.act_id == 1][-1]
1143 assert freed_1.frame_freed == True, "frame_freed should be True (last lane)"
1144 assert freed_1.lane == lane_1, "Event should report lane 1"
1145
1146 # Summary: verify AC8.6 acceptance criteria
1147 # Both iterations produce mathematically correct results
1148 assert matches_by_id[0][-1].left + matches_by_id[0][-1].right == 300, \
1149 "Iteration 1 arithmetic correct"
1150 assert matches_by_id[1][-1].left + matches_by_id[1][-1].right == 3000, \
1151 "Iteration 2 arithmetic correct"
1152
1153 # Both iterations ran on SAME frame (verified at allocation, re-confirmed)
1154 assert frame_id_1 == frame_id, "Both iterations ran on same frame"
1155
1156 # Both iterations used DIFFERENT lanes
1157 assert lane_0 != lane_1, "Iterations used different lanes"
1158 assert lane_0 == 0 and lane_1 == 1, "Lanes are 0 and 1 respectively"
1159
1160 # Freeing one iteration preserved the other
1161 frame_freed_events = [e for e in events if isinstance(e, FrameFreed)]
1162 assert len(frame_freed_events) >= 2, "Should have 2 FrameFreed events"
1163
1164 # Freeing the last iteration returned the frame
1165 assert frame_id in pe.free_frames, "Frame returned to pool after last FREE"