From 5482d3995b7a43ffb8a0bcdac19eb70a1d901861 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Thu, 15 Oct 2026 15:13:26 +0000 Subject: [PATCH 1/3] fix: a transient decoder loss no longer drops the learnt capacity to the floor (#189) - Losses at a load below a proven one (played PROBE_QUIET_MS without losses) count as a transient: the lowest full cell is demoted as before, but proven, failedLoad and the capacity (at least proven) stay. A second such demotion within TRANSIENT_WINDOW_MS (10 min) means the proven load was wrong: handled as any failure, as before. - The retry of a failed load (FAILED_RETRY_MS) is timed from the last demotion (or the last retry), not from the last lossy sample, which key-frame cells keep resetting. - When the highest-priority key-frame cell is held back (a failed load, or it does not fit and no probe is due), the first lower-priority cell that fits within the capacity and stays below the failed load is promoted. Probes and retries stay with the first cell. Unit tests for each case, including the #189 timeline and stored values. No existing test changed: none relied on the changed behaviour. versionName 2.10, versionCode 35. --- android/app/build.gradle | 4 +- .../com/openipc/decoder/DecodePlanner.java | 64 +++++++-- .../openipc/decoder/DecodePlannerTest.java | 129 ++++++++++++++++++ 3 files changed, 184 insertions(+), 13 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index c517b841..a725c471 100755 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -23,8 +23,8 @@ android { applicationId "com.openipc.decoder" minSdk 21 targetSdk 36 - versionCode 34 - versionName "2.09" + versionCode 35 + versionName "2.10" buildConfigField "String", "GIT_HASH", "\"${getGitHash()}\"" } diff --git a/android/app/src/main/java/com/openipc/decoder/DecodePlanner.java b/android/app/src/main/java/com/openipc/decoder/DecodePlanner.java index ad2d5132..6163b201 100644 --- a/android/app/src/main/java/com/openipc/decoder/DecodePlanner.java +++ b/android/app/src/main/java/com/openipc/decoder/DecodePlanner.java @@ -41,8 +41,18 @@ final class DecodePlanner { static final long PROBE_MAX_WAIT_MS = 10 * 60_000; /** Promotions stay this far below a load that failed: the load estimates are noisy. */ static final double FAILED_MARGIN = 0.9; - /** Quiet time after which a load that failed is tried once more: it may have been a fluke. */ + /** + * Time after the last demotion (or the last such retry) after which a load that failed is + * tried once more: it may have been a fluke. Not from the last lossy sample, which key-frame + * cells reset now and then (#189). + */ static final long FAILED_RETRY_MS = 60 * 60_000; + /** + * Losses below a proven load count as a transient (#189): demoted, but the capacity and the + * failed load stay at least at the proven load. A second such demotion within this time + * means the proven load is wrong after all: it is handled as any other failure. + */ + static final long TRANSIENT_WINDOW_MS = 10 * 60_000; /** * After the grid starts, every decoder starts at once and all of them lose frames for a * second or two: no decision on losses that early (#148). Set by the app; 0 in tests. @@ -109,6 +119,10 @@ static final class Decision { private long resumeWaitMs = PROBE_QUIET_MS; /** The last lossy sample; promotions do not move it, unlike quietSinceMs. */ private long lastLossMs = -1; + /** The last demotion or failed-load retry: the next retry is timed from it (#189). */ + private long lastFailMs = -1; + /** The last demotion counted as a transient, or -1 (#189). */ + private long lastTransientMs = -1; /** No loss-based decision for this long after the first step (#148). */ void setWarmup(long ms) { warmupMs = ms; } @@ -181,6 +195,7 @@ Decision step(List cells, long nowMs) { lossyTimes.pollFirst(); } if (lastLossMs < 0) lastLossMs = nowMs; + if (lastFailMs < 0) lastFailMs = nowMs; if (anyLossy) { lossyTimes.addLast(nowMs); quietSinceMs = nowMs; @@ -203,6 +218,18 @@ Decision step(List cells, long nowMs) { for (int i = cells.size() - 1; i >= 0; i--) { Cell c = cells.get(i); if (c.full && !c.paused && c.playing && c.known) { + lastFailMs = nowMs; + // Below a load that played quietly: a transient (other apps, a stalled + // stream), unless one came shortly before; only the cell goes back (#189) + boolean repeated = lastTransientMs >= 0 + && nowMs - lastTransientMs < TRANSIENT_WINDOW_MS; + if (load < proven && !repeated) { + lastTransientMs = nowMs; + capacity = Math.max(minCapacity, Math.max(proven, Math.min(capacity, + Math.round(load * 0.9)))); + learnt = true; + return change(nowMs, Action.DEMOTE, c.index); + } // Losses at this load: the capacity is below it, but not below a proven load. // Remembered for this session even below the floor; only stored values at // or below it are distrusted after a restart (#126) @@ -244,7 +271,9 @@ Decision step(List cells, long nowMs) { } } - // Promote the highest-priority key-frame cell whose load is known + // Promote the highest-priority key-frame cell whose load is known; when it is held back, + // a lower one that fits within the capacity and below a failed load (#189) + boolean first = true; for (Cell c : cells) { if (c.full || c.paused || !c.playing || c.pinned) { // Not connected (an offline camera) or paused: nothing to wait for @@ -260,24 +289,37 @@ Decision step(List cells, long nowMs) { } unknownSince.remove(c.index); long after = load - c.keyLoad + c.fullLoad; - // Never up to a load that failed, not even within the capacity, unless it has been - // quiet for very long - if (after >= failedLoad * FAILED_MARGIN - && nowMs - quietSinceMs < FAILED_RETRY_MS) return Decision.NONE; + boolean belowFailed = after < failedLoad * FAILED_MARGIN; boolean fits = after <= capacity; + if (!first) { + // Out of priority order only what surely fits: no probe, no retry (#189) + if (fits && belowFailed) return promote(nowMs, c.index); + continue; + } + first = false; + // Never up to a load that failed, not even within the capacity, unless the last + // failure was very long ago; that retry restarts the wait (#189) + boolean retry = !belowFailed && nowMs - lastFailMs >= FAILED_RETRY_MS; + if (!belowFailed && !retry) continue; // Beyond the capacity only after a quiet period boolean probe = nowMs - quietSinceMs >= probeWaitMs; if (fits || probe) { - // Every promotion starts a new quiet period: a load counts as proven only - // after it has itself played for PROBE_QUIET_MS without losses - quietSinceMs = nowMs; - return change(nowMs, Action.PROMOTE, c.index); + if (retry) lastFailMs = nowMs; + return promote(nowMs, c.index); } - return Decision.NONE; } return Decision.NONE; } + /** + * Every promotion starts a new quiet period: a load counts as proven only after it has + * itself played for PROBE_QUIET_MS without losses. + */ + private Decision promote(long nowMs, int index) { + quietSinceMs = nowMs; + return change(nowMs, Action.PROMOTE, index); + } + private static int countPlaying(List cells) { int n = 0; for (Cell c : cells) if (!c.paused && c.playing) n++; diff --git a/android/app/src/test/java/com/openipc/decoder/DecodePlannerTest.java b/android/app/src/test/java/com/openipc/decoder/DecodePlannerTest.java index 748f5c44..2e3e1b00 100644 --- a/android/app/src/test/java/com/openipc/decoder/DecodePlannerTest.java +++ b/android/app/src/test/java/com/openipc/decoder/DecodePlannerTest.java @@ -406,4 +406,133 @@ public void aSoftwareCellNeitherLoadsNorPausesTheHardware() { assertTrue(!cells.get(0).paused && !cells.get(1).paused); assertEquals(P1080_30, p.capacity()); } + + /** Steps once per second until a decision other than NONE, at most {@code ms}; applied. */ + private static DecodePlanner.Decision until(DecodePlanner p, List cells, + long[] t, long ms) { + DecodePlanner.Decision d = DecodePlanner.Decision.NONE; + for (long end = t[0] + ms; d.action == DecodePlanner.Action.NONE && t[0] < end; t[0] += 1000) { + d = p.step(cells, t[0]); + apply(cells, d); + } + return d; + } + + /** Three cells play a load of {@code each} x 3 quietly until it is proven. */ + private static List provenAtThree(DecodePlanner p, long each, long[] t) { + List cells = keyCells(3, each); + for (int i = 0; i < 3; i++) until(p, cells, t, 10_000); + assertEquals(3, full(cells)); + for (long end = t[0] + DecodePlanner.PROBE_QUIET_MS + 1000; t[0] < end; t[0] += 1000) { + assertEquals(DecodePlanner.Action.NONE, p.step(cells, t[0]).action); + } + return cells; + } + + @Test + public void aTransientLossBelowAProvenLoadKeepsTheCapacity() { + // The #189 timeline: 312 338 proven, then losses at 239 719 (other apps, a stall) + long floor = DecodePlanner.fullLoad(1920, 1080, 30); + DecodePlanner p = new DecodePlanner(464_979, true, 446_770, floor); + long[] t = {0}; + List cells = provenAtThree(p, 312_338 / 3, t); + for (DecodePlanner.Cell c : cells) c.fullLoad = 239_719 / 3; + cells.get(0).lossy = true; + DecodePlanner.Decision d = until(p, cells, t, 10_000); + assertEquals(DecodePlanner.Action.DEMOTE, d.action); + assertEquals(2, d.index); + assertTrue("not below the proven load", p.capacity() >= 312_336); + assertEquals("the failed load stays", 446_770, p.failedLoad()); + // The losses stop: the cell goes back to full rate at once + cells.get(0).lossy = false; + d = until(p, cells, t, 10_000); + assertEquals(DecodePlanner.Action.PROMOTE, d.action); + assertEquals(2, d.index); + } + + @Test + public void repeatedLossesBelowAProvenLoadCountAsAFailure() { + long floor = DecodePlanner.fullLoad(1920, 1080, 30); + DecodePlanner p = new DecodePlanner(464_979, true, 446_770, floor); + long[] t = {0}; + List cells = provenAtThree(p, 312_338 / 3, t); + long each = 239_719 / 3; + for (DecodePlanner.Cell c : cells) c.fullLoad = each; + cells.get(0).lossy = true; + assertEquals(DecodePlanner.Action.DEMOTE, until(p, cells, t, 10_000).action); + cells.get(0).lossy = false; + assertEquals(DecodePlanner.Action.PROMOTE, until(p, cells, t, 10_000).action); + // The same losses again a few minutes later: the proven load was wrong after all + for (long end = t[0] + 3 * 60_000; t[0] < end; t[0] += 1000) apply(cells, p.step(cells, t[0])); + assertEquals(3, full(cells)); + cells.get(0).lossy = true; + DecodePlanner.Decision d = until(p, cells, t, 10_000); + assertEquals(DecodePlanner.Action.DEMOTE, d.action); + assertEquals(3 * each, p.failedLoad()); + assertEquals(floor, p.capacity()); + } + + @Test + public void aLightCellIsPromotedWhenTheFirstOneIsHeldBackByAFailedLoad() { + // The stored values of #189: capacity at the floor, failed at 239 719; nine cells on key + // frames, about 50 000 blocks/s in all + long floor = DecodePlanner.fullLoad(1920, 1080, 30); + DecodePlanner p = new DecodePlanner(floor, true, 239_719, floor); + List cells = keyCells(9, 0); + cells.get(0).fullLoad = 180_000; // the U cell: up to about 221 000, too close to 239 719 + cells.get(0).keyLoad = 9_000; + for (int i = 1; i < 8; i++) { + cells.get(i).fullLoad = DecodePlanner.fullLoad(1920, 1080, 20); // about 162 000 + cells.get(i).keyLoad = 5_000; + } + cells.get(8).fullLoad = DecodePlanner.fullLoad(1280, 720, 2); // about 7 000 + cells.get(8).keyLoad = 6_000; + long[] t = {0}; + for (int i = 0; i < 20; i++) until(p, cells, t, 1000); + assertTrue(!cells.get(0).full); + assertTrue("the first that fits plays at full rate", cells.get(1).full); + assertTrue("and the light one", cells.get(8).full); + assertEquals(2, full(cells)); + } + + @Test + public void onlyTheFirstCellIsProbedBeyondTheCapacity() { + DecodePlanner p = new DecodePlanner(P1080_30, false); + List cells = keyCells(3, P1080_30); + cells.get(0).fullLoad = 2 * P1080_30; // does not fit + cells.get(1).fullLoad = P1080_30 / 2; // fits + long[] t = {0}; + DecodePlanner.Decision d = until(p, cells, t, 1000); + assertEquals(DecodePlanner.Action.PROMOTE, d.action); + assertEquals("a lower cell that fits does not wait", 1, d.index); + // The third would fit a probe as well, but the probe keeps the priority order + d = until(p, cells, t, 2 * DecodePlanner.PROBE_QUIET_MS); + assertEquals(DecodePlanner.Action.PROMOTE, d.action); + assertEquals(0, d.index); + } + + @Test + public void aFailedLoadIsRetriedAnHourAfterTheDemotionDespiteSparseLosses() { + DecodePlanner p = new DecodePlanner(P1080_30, false); + List cells = keyCells(2, P1080_30); + long[] t = {0}; + until(p, cells, t, 1000); + until(p, cells, t, 40_000); // the probe of the second camera + assertEquals(2, full(cells)); + cells.get(0).lossy = true; + assertEquals(DecodePlanner.Action.DEMOTE, until(p, cells, t, 10_000).action); + long demotedAt = t[0]; + cells.get(0).lossy = false; + // A single lost key frame now and then: never a demotion, but no quiet hour either + DecodePlanner.Decision d = DecodePlanner.Decision.NONE; + for (; d.action == DecodePlanner.Action.NONE && t[0] < demotedAt + 2 * DecodePlanner.FAILED_RETRY_MS; + t[0] += 1000) { + cells.get(1).lossy = (t[0] - demotedAt) % (20 * 60_000) < 1000; + d = p.step(cells, t[0]); + } + assertEquals(DecodePlanner.Action.PROMOTE, d.action); + assertEquals(1, d.index); + assertTrue(t[0] - demotedAt >= DecodePlanner.FAILED_RETRY_MS); + assertTrue(t[0] - demotedAt < DecodePlanner.FAILED_RETRY_MS + 20 * 60_000); + } } From e7b91b369b506320b294b1fcf4316645ac5a605c Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Thu, 15 Oct 2026 15:15:35 +0000 Subject: [PATCH 2/3] test: the #189 promotion case with the in-session failed load (a stored one below the floor is not trusted) --- .../test/java/com/openipc/decoder/DecodePlannerTest.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/android/app/src/test/java/com/openipc/decoder/DecodePlannerTest.java b/android/app/src/test/java/com/openipc/decoder/DecodePlannerTest.java index 2e3e1b00..4523abb7 100644 --- a/android/app/src/test/java/com/openipc/decoder/DecodePlannerTest.java +++ b/android/app/src/test/java/com/openipc/decoder/DecodePlannerTest.java @@ -474,10 +474,10 @@ public void repeatedLossesBelowAProvenLoadCountAsAFailure() { @Test public void aLightCellIsPromotedWhenTheFirstOneIsHeldBackByAFailedLoad() { - // The stored values of #189: capacity at the floor, failed at 239 719; nine cells on key + // The state of #189 in the session after the demotion: capacity at the floor, failed at + // 239 719 (after a restart, a value below the floor is not trusted); nine cells on key // frames, about 50 000 blocks/s in all - long floor = DecodePlanner.fullLoad(1920, 1080, 30); - DecodePlanner p = new DecodePlanner(floor, true, 239_719, floor); + DecodePlanner p = new DecodePlanner(DecodePlanner.fullLoad(1920, 1080, 30), true, 239_719); List cells = keyCells(9, 0); cells.get(0).fullLoad = 180_000; // the U cell: up to about 221 000, too close to 239 719 cells.get(0).keyLoad = 9_000; From d6593897f2656cde74b8feff45036204f9ebaafb Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Thu, 15 Oct 2026 15:19:00 +0000 Subject: [PATCH 3/3] fix: a transient below the proven load is forgiven once until that load plays quietly again, not once per 10 minutes (Qodo on #190) --- .../com/openipc/decoder/DecodePlanner.java | 45 ++++++++++--------- .../openipc/decoder/DecodePlannerTest.java | 28 +++++++++++- 2 files changed, 49 insertions(+), 24 deletions(-) diff --git a/android/app/src/main/java/com/openipc/decoder/DecodePlanner.java b/android/app/src/main/java/com/openipc/decoder/DecodePlanner.java index 6163b201..bec1f026 100644 --- a/android/app/src/main/java/com/openipc/decoder/DecodePlanner.java +++ b/android/app/src/main/java/com/openipc/decoder/DecodePlanner.java @@ -47,12 +47,6 @@ final class DecodePlanner { * cells reset now and then (#189). */ static final long FAILED_RETRY_MS = 60 * 60_000; - /** - * Losses below a proven load count as a transient (#189): demoted, but the capacity and the - * failed load stay at least at the proven load. A second such demotion within this time - * means the proven load is wrong after all: it is handled as any other failure. - */ - static final long TRANSIENT_WINDOW_MS = 10 * 60_000; /** * After the grid starts, every decoder starts at once and all of them lose frames for a * second or two: no decision on losses that early (#148). Set by the app; 0 in tests. @@ -121,8 +115,13 @@ static final class Decision { private long lastLossMs = -1; /** The last demotion or failed-load retry: the next retry is timed from it (#189). */ private long lastFailMs = -1; - /** The last demotion counted as a transient, or -1 (#189). */ - private long lastTransientMs = -1; + /** + * Losses below a proven load count as a transient once (#189): demoted, but the capacity + * and the failed load stay at least at the proven load. The next such demotion is handled + * as any other failure (the proven load was wrong after all), unless a load at least as + * high has played PROBE_QUIET_MS without losses again in between (Qodo on #190). + */ + private boolean transientUsed; /** No loss-based decision for this long after the first step (#148). */ void setWarmup(long ms) { warmupMs = ms; } @@ -200,16 +199,20 @@ Decision step(List cells, long nowMs) { lossyTimes.addLast(nowMs); quietSinceMs = nowMs; lastLossMs = nowMs; - } else if (nowMs - quietSinceMs >= PROBE_QUIET_MS && load > proven) { - // A load that played without losses long enough is within this device's means - proven = load; - probeWaitMs = PROBE_QUIET_MS; - if (load > capacity) { - capacity = load; - learnt = true; + } else if (nowMs - quietSinceMs >= PROBE_QUIET_MS && load >= proven) { + // Proven again: a later transient is forgiven once more (#189) + transientUsed = false; + if (load > proven) { + // A load that played without losses long enough is within this device's means + proven = load; + probeWaitMs = PROBE_QUIET_MS; + if (load > capacity) { + capacity = load; + learnt = true; + } + // A failure at about this load was a fluke: do not hold promotions back by it + if (load >= failedLoad * FAILED_MARGIN) failedLoad = Long.MAX_VALUE; } - // A failure at about this load was a fluke: do not hold promotions back by it - if (load >= failedLoad * FAILED_MARGIN) failedLoad = Long.MAX_VALUE; } if (lastChangeMs >= 0 && nowMs - lastChangeMs < SETTLE_MS) return Decision.NONE; @@ -220,11 +223,9 @@ Decision step(List cells, long nowMs) { if (c.full && !c.paused && c.playing && c.known) { lastFailMs = nowMs; // Below a load that played quietly: a transient (other apps, a stalled - // stream), unless one came shortly before; only the cell goes back (#189) - boolean repeated = lastTransientMs >= 0 - && nowMs - lastTransientMs < TRANSIENT_WINDOW_MS; - if (load < proven && !repeated) { - lastTransientMs = nowMs; + // stream), the first time; only the cell goes back (#189) + if (load < proven && !transientUsed) { + transientUsed = true; capacity = Math.max(minCapacity, Math.max(proven, Math.min(capacity, Math.round(load * 0.9)))); learnt = true; diff --git a/android/app/src/test/java/com/openipc/decoder/DecodePlannerTest.java b/android/app/src/test/java/com/openipc/decoder/DecodePlannerTest.java index 4523abb7..c5c25dff 100644 --- a/android/app/src/test/java/com/openipc/decoder/DecodePlannerTest.java +++ b/android/app/src/test/java/com/openipc/decoder/DecodePlannerTest.java @@ -462,8 +462,8 @@ public void repeatedLossesBelowAProvenLoadCountAsAFailure() { assertEquals(DecodePlanner.Action.DEMOTE, until(p, cells, t, 10_000).action); cells.get(0).lossy = false; assertEquals(DecodePlanner.Action.PROMOTE, until(p, cells, t, 10_000).action); - // The same losses again a few minutes later: the proven load was wrong after all - for (long end = t[0] + 3 * 60_000; t[0] < end; t[0] += 1000) apply(cells, p.step(cells, t[0])); + // The same losses again, even much later (Qodo on #190): the proven load was wrong + for (long end = t[0] + 15 * 60_000; t[0] < end; t[0] += 1000) apply(cells, p.step(cells, t[0])); assertEquals(3, full(cells)); cells.get(0).lossy = true; DecodePlanner.Decision d = until(p, cells, t, 10_000); @@ -472,6 +472,30 @@ public void repeatedLossesBelowAProvenLoadCountAsAFailure() { assertEquals(floor, p.capacity()); } + @Test + public void aTransientIsForgivenAgainOnceTheProvenLoadPlaysQuietlyAgain() { + long floor = DecodePlanner.fullLoad(1920, 1080, 30); + DecodePlanner p = new DecodePlanner(464_979, true, 446_770, floor); + long[] t = {0}; + List cells = provenAtThree(p, 312_338 / 3, t); + for (DecodePlanner.Cell c : cells) c.fullLoad = 239_719 / 3; + cells.get(0).lossy = true; + assertEquals(DecodePlanner.Action.DEMOTE, until(p, cells, t, 10_000).action); + cells.get(0).lossy = false; + assertEquals(DecodePlanner.Action.PROMOTE, until(p, cells, t, 10_000).action); + // The proven load plays without losses again + for (DecodePlanner.Cell c : cells) c.fullLoad = 312_338 / 3; + for (long end = t[0] + DecodePlanner.PROBE_QUIET_MS + 1000; t[0] < end; t[0] += 1000) { + apply(cells, p.step(cells, t[0])); + } + // Another transient: still not a failure + for (DecodePlanner.Cell c : cells) c.fullLoad = 239_719 / 3; + cells.get(0).lossy = true; + assertEquals(DecodePlanner.Action.DEMOTE, until(p, cells, t, 10_000).action); + assertTrue(p.capacity() >= 312_336); + assertEquals(446_770, p.failedLoad()); + } + @Test public void aLightCellIsPromotedWhenTheFirstOneIsHeldBackByAFailedLoad() { // The state of #189 in the session after the demotion: capacity at the floor, failed at