Skip to content

Commit 342627a

Browse files
committed
Ignore per-tick furnace burn noise
1 parent 0080735 commit 342627a

4 files changed

Lines changed: 91 additions & 32 deletions

File tree

.github/workflows/phase2-runtime-ab.yml

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ jobs:
212212
-OutputJson phase2-results/clean/blockUpdateMs.analysis.json -Overwrite
213213
fi
214214
215-
python3 - "$MANIFEST" "$SCENARIO" "$RUNS" \
215+
python3 - "$MANIFEST" "$SCENARIO" "$RUNS" "$ITEMS" \
216216
phase2-results/clean/blockUpdateChecks.evidence.json <<'PY'
217217
from pathlib import Path
218218
import csv
@@ -225,7 +225,8 @@ jobs:
225225
manifest_path = Path(sys.argv[1])
226226
scenario = sys.argv[2]
227227
expected_runs = int(sys.argv[3])
228-
output_path = Path(sys.argv[4])
228+
expected_items = int(sys.argv[4])
229+
output_path = Path(sys.argv[5])
229230
records = []
230231
with manifest_path.open(encoding="utf-8", newline="") as stream:
231232
rows = list(csv.DictReader(stream))
@@ -249,6 +250,9 @@ jobs:
249250
raise SystemExit(f"invalid blockUpdateMs for {run_id}: {elapsed_ms!r}")
250251
if checks > 0 and elapsed_ms <= 0:
251252
raise SystemExit(f"{run_id} recorded {checks} checks but no elapsed time")
253+
tick_samples = metrics.get("tickSamples")
254+
if isinstance(tick_samples, bool) or not isinstance(tick_samples, int) or tick_samples <= 0:
255+
raise SystemExit(f"invalid tickSamples for {run_id}: {tick_samples!r}")
252256
expected_event_driven = row["Variant"] == "B"
253257
if metrics.get("eventDrivenBlockUpdates") is not expected_event_driven:
254258
raise SystemExit(f"event-driven config mismatch for {run_id}")
@@ -258,6 +262,8 @@ jobs:
258262
"variant": row["Variant"],
259263
"runId": run_id,
260264
"blockUpdateChecks": checks,
265+
"tickSamples": tick_samples,
266+
"checksPerTick": checks / tick_samples,
261267
"blockUpdateMs": elapsed_ms,
262268
"msPerCheck": elapsed_ms / checks if checks else None,
263269
"sourcePath": source_path.as_posix(),
@@ -266,6 +272,7 @@ jobs:
266272
def summarize(variant):
267273
selected = [record for record in records if record["variant"] == variant]
268274
checks = [record["blockUpdateChecks"] for record in selected]
275+
checks_per_tick = [record["checksPerTick"] for record in selected]
269276
times = [record["blockUpdateMs"] for record in selected]
270277
return {
271278
"runCount": len(selected),
@@ -274,18 +281,48 @@ jobs:
274281
"median": statistics.median(checks),
275282
"mean": statistics.fmean(checks),
276283
},
284+
"checksPerTick": {
285+
"values": checks_per_tick,
286+
"median": statistics.median(checks_per_tick),
287+
"mean": statistics.fmean(checks_per_tick),
288+
},
277289
"blockUpdateMs": {
278290
"values": times,
279291
"median": statistics.median(times),
280292
"mean": statistics.fmean(times),
281293
},
282294
}
283295
296+
by_variant = {"A": summarize("A"), "B": summarize("B")}
297+
cadence_guard = {
298+
"evaluated": False,
299+
"reason": "only block-active workloads with at least 100 blocks have a stable mixed-type cadence",
300+
}
301+
if scenario == "block-active" and expected_items >= 100:
302+
baseline = by_variant["A"]["checksPerTick"]["median"]
303+
candidate = by_variant["B"]["checksPerTick"]["median"]
304+
if baseline <= 0:
305+
raise SystemExit("block-active baseline recorded no checks")
306+
ratio = candidate / baseline
307+
cadence_guard = {
308+
"evaluated": True,
309+
"minimumCandidateToBaselineRatio": 0.60,
310+
"maximumCandidateToBaselineRatio": 0.85,
311+
"candidateToBaselineRatio": ratio,
312+
"passed": 0.60 <= ratio <= 0.85,
313+
"interpretation": (
314+
"The active candidate aggregate rate must remain consistent with all three furnace "
315+
"cadences while eliminating "
316+
"idle bee polling and noisy level-event invalidations."
317+
),
318+
}
319+
284320
evidence = {
285321
"schemaVersion": 1,
286322
"analysisType": "diagnostic-work-evidence",
287323
"scenario": scenario,
288324
"requestedRuns": expected_runs,
325+
"expectedItems": expected_items,
289326
"formalComplete": expected_runs == 12,
290327
"blockUpdateChecksDirection": None,
291328
"blockUpdateMsDirection": None if scenario == "block-idle" else "LowerIsBetter",
@@ -296,7 +333,8 @@ jobs:
296333
"has no ratio analysis. Interpret all scenarios with machine-validated scene actions, "
297334
"blockUpdateMs, MSPT and TPS."
298335
),
299-
"byVariant": {"A": summarize("A"), "B": summarize("B")},
336+
"activeCadenceGuard": cadence_guard,
337+
"byVariant": by_variant,
300338
"runs": records,
301339
}
302340
output_path.write_text(
@@ -309,16 +347,30 @@ jobs:
309347
with open(step_summary, "a", encoding="utf-8", newline="\n") as stream:
310348
stream.write(f"### {scenario} block update evidence\n\n")
311349
stream.write("`blockUpdateChecks` is diagnostic work evidence (no universal direction).\n\n")
312-
stream.write("| Run | Variant | Checks | Update ms | ms/check |\n")
313-
stream.write("|---|---:|---:|---:|---:|\n")
350+
stream.write("| Run | Variant | Checks | Checks/tick | Update ms | ms/check |\n")
351+
stream.write("|---|---:|---:|---:|---:|---:|\n")
314352
for record in records:
315353
per_check = record["msPerCheck"]
316354
per_check_text = "n/a" if per_check is None else f"{per_check:.9f}"
317355
stream.write(
318356
f"| {record['runId']} | {record['variant']} | "
319-
f"{record['blockUpdateChecks']} | {record['blockUpdateMs']:.6f} | "
357+
f"{record['blockUpdateChecks']} | {record['checksPerTick']:.6f} | "
358+
f"{record['blockUpdateMs']:.6f} | "
320359
f"{per_check_text} |\n"
321360
)
361+
if cadence_guard["evaluated"]:
362+
stream.write(
363+
"\nActive cadence guard: "
364+
f"`{'pass' if cadence_guard['passed'] else 'fail'}`; "
365+
f"candidate/baseline=`{cadence_guard['candidateToBaselineRatio']:.6f}` "
366+
"(required `0.60..0.85`).\n"
367+
)
368+
369+
if cadence_guard["evaluated"] and not cadence_guard["passed"]:
370+
raise SystemExit(
371+
"block-active candidate checks/tick escaped the expected mixed-type cadence: "
372+
f"ratio={cadence_guard['candidateToBaselineRatio']:.6f}"
373+
)
322374
PY
323375
fi
324376

common/src/main/java/com/loohp/interactionvisualizer/debug/PerformanceBlockScene.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -587,7 +587,7 @@ private static void configureFurnace(Furnace furnace, Material material, Mode mo
587587
inventory.setSmelting(new ItemStack(input, amount));
588588
if (mode == Mode.ACTIVE) {
589589
// Vanilla consumes this fuel on its next tick and dispatches the real
590-
// FurnaceBurnEvent/FurnaceStartSmeltEvent sequence.
590+
// FurnaceStartSmeltEvent edge used by the event-driven updater.
591591
inventory.setFuel(new ItemStack(Material.COAL_BLOCK));
592592
}
593593
furnace.setBurnTime((short) 0);

common/src/main/java/com/loohp/interactionvisualizer/managers/EventDrivenBlockUpdateListener.java

Lines changed: 7 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,6 @@
4545
import org.bukkit.event.entity.EntityChangeBlockEvent;
4646
import org.bukkit.event.entity.EntityEnterBlockEvent;
4747
import org.bukkit.event.entity.EntityExplodeEvent;
48-
import org.bukkit.event.inventory.FurnaceBurnEvent;
4948
import org.bukkit.event.inventory.FurnaceExtractEvent;
5049
import org.bukkit.event.inventory.FurnaceSmeltEvent;
5150
import org.bukkit.event.inventory.FurnaceStartSmeltEvent;
@@ -100,29 +99,13 @@ public boolean isEmpty() {
10099
return furnace == null && blastFurnace == null && smoker == null && beeHive == null && beeNest == null;
101100
}
102101

103-
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
104-
public void onFurnaceBurn(FurnaceBurnEvent event) {
105-
switch (furnaceTarget(event.getBlock().getType())) {
106-
case FURNACE -> {
107-
if (furnace != null) {
108-
furnace.onFurnaceBurn(event);
109-
}
110-
}
111-
case BLAST_FURNACE -> {
112-
if (blastFurnace != null) {
113-
blastFurnace.onBlastFurnaceBurn(event);
114-
}
115-
}
116-
case SMOKER -> {
117-
if (smoker != null) {
118-
smoker.onSmokerBurn(event);
119-
}
120-
}
121-
case NONE -> {
122-
}
123-
}
124-
}
125-
102+
/*
103+
* FurnaceBurnEvent is intentionally not part of this listener. Paper
104+
* 26.1.2 emits it as a per-tick level signal for processing furnaces, so
105+
* treating it as a dirty edge permanently saturates all three furnace
106+
* schedulers. StartSmelt/Smelt/inventory/lifecycle edges bootstrap work;
107+
* the active cadence owns progress and fuel-state refreshes thereafter.
108+
*/
126109
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
127110
public void onFurnaceStartSmelt(FurnaceStartSmeltEvent event) {
128111
switch (furnaceTarget(event.getBlock().getType())) {

common/src/test/java/com/loohp/interactionvisualizer/managers/EventDrivenBlockUpdateListenerRegistrationTest.java

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import com.loohp.interactionvisualizer.api.events.TileEntityRemovedEvent;
1515
import com.loohp.interactionvisualizer.blocks.BeeHiveDisplay;
1616
import com.loohp.interactionvisualizer.blocks.BeeNestDisplay;
17+
import com.loohp.interactionvisualizer.blocks.BlastFurnaceDisplay;
1718
import com.loohp.interactionvisualizer.blocks.FurnaceDisplay;
1819
import com.loohp.interactionvisualizer.blocks.SmokerDisplay;
1920
import org.bukkit.Material;
@@ -25,7 +26,11 @@
2526
import org.bukkit.event.entity.EntityChangeBlockEvent;
2627
import org.bukkit.event.entity.EntityEnterBlockEvent;
2728
import org.bukkit.event.inventory.FurnaceBurnEvent;
29+
import org.bukkit.event.inventory.FurnaceExtractEvent;
30+
import org.bukkit.event.inventory.FurnaceSmeltEvent;
31+
import org.bukkit.event.inventory.FurnaceStartSmeltEvent;
2832
import org.bukkit.event.inventory.InventoryClickEvent;
33+
import org.bukkit.event.inventory.InventoryMoveItemEvent;
2934
import org.bukkit.event.player.PlayerMoveEvent;
3035
import org.bukkit.event.player.PlayerInteractEvent;
3136
import org.bukkit.event.world.ChunkLoadEvent;
@@ -34,6 +39,7 @@
3439
import java.lang.reflect.Method;
3540

3641
import static org.junit.jupiter.api.Assertions.assertEquals;
42+
import static org.junit.jupiter.api.Assertions.assertFalse;
3743
import static org.junit.jupiter.api.Assertions.assertNotNull;
3844
import static org.junit.jupiter.api.Assertions.assertNull;
3945

@@ -42,6 +48,8 @@ class EventDrivenBlockUpdateListenerRegistrationTest {
4248
@Test
4349
void eventDrivenOnlyHandlersAreAbsentFromAlwaysRegisteredListeners() throws Exception {
4450
assertNotRegistered(FurnaceDisplay.class, "onFurnaceBurn", FurnaceBurnEvent.class);
51+
assertNotRegistered(BlastFurnaceDisplay.class, "onBlastFurnaceBurn", FurnaceBurnEvent.class);
52+
assertNotRegistered(SmokerDisplay.class, "onSmokerBurn", FurnaceBurnEvent.class);
4553
assertNotRegistered(BeeHiveDisplay.class, "onAffectedBlockPlace", BlockPlaceEvent.class);
4654
assertNotRegistered(SmokerDisplay.class, "onRemoveSmoker", TileEntityRemovedEvent.class);
4755
assertNotRegistered(TileEntityManager.class, "onChunkLoad", ChunkLoadEvent.class);
@@ -57,7 +65,14 @@ void legacyHandlersRemainOnAlwaysRegisteredListeners() throws Exception {
5765

5866
@Test
5967
void conditionalListenerOwnsTheEventDrivenSurface() throws Exception {
60-
assertRegistered(EventDrivenBlockUpdateListener.class, "onFurnaceBurn", FurnaceBurnEvent.class);
68+
// Paper 26.1.2 emits FurnaceBurnEvent for every processing furnace on
69+
// every tick. It is a level signal, not an invalidation edge; routing it
70+
// would permanently saturate each furnace scheduler's dirty budget.
71+
assertNoRegisteredHandler(EventDrivenBlockUpdateListener.class, FurnaceBurnEvent.class);
72+
assertRegistered(EventDrivenBlockUpdateListener.class, "onFurnaceStartSmelt", FurnaceStartSmeltEvent.class);
73+
assertRegistered(EventDrivenBlockUpdateListener.class, "onFurnaceSmelt", FurnaceSmeltEvent.class);
74+
assertRegistered(EventDrivenBlockUpdateListener.class, "onFurnaceExtract", FurnaceExtractEvent.class);
75+
assertRegistered(EventDrivenBlockUpdateListener.class, "onInventoryMoveItem", InventoryMoveItemEvent.class);
6176
assertRegistered(EventDrivenBlockUpdateListener.class, "onAffectedBlockPlace", BlockPlaceEvent.class);
6277
assertRegistered(EventDrivenBlockUpdateListener.class, "onBeeEnterBlock", EntityEnterBlockEvent.class);
6378
assertRegistered(EventDrivenBlockUpdateListener.class, "onEntityChangeBlock", EntityChangeBlockEvent.class);
@@ -112,4 +127,13 @@ private static void assertNotRegistered(Class<?> type, String methodName, Class<
112127
Method method = type.getDeclaredMethod(methodName, eventType);
113128
assertNull(method.getAnnotation(EventHandler.class), type.getSimpleName() + "." + methodName);
114129
}
130+
131+
private static void assertNoRegisteredHandler(Class<?> type, Class<?> eventType) {
132+
for (Method method : type.getDeclaredMethods()) {
133+
Class<?>[] parameters = method.getParameterTypes();
134+
boolean catchesEvent = parameters.length == 1 && parameters[0].isAssignableFrom(eventType);
135+
assertFalse(catchesEvent && method.getAnnotation(EventHandler.class) != null,
136+
type.getSimpleName() + "." + method.getName());
137+
}
138+
}
115139
}

0 commit comments

Comments
 (0)