diff --git a/src/main/java/dev/marcospaulo/canalhandia/Conversations.java b/src/main/java/dev/marcospaulo/canalhandia/Conversations.java new file mode 100644 index 0000000..8f4b03c --- /dev/null +++ b/src/main/java/dev/marcospaulo/canalhandia/Conversations.java @@ -0,0 +1,141 @@ +package dev.marcospaulo.canalhandia; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * Short per-player memory, so a follow-up like "e no nether?" makes sense. + * + *

Deliberately small and forgetful: the point is continuity within one + * exchange, not a transcript. Every way of losing memory here — expiry, + * eviction, a restart — costs one player one lost follow-up, so this class + * always prefers forgetting to growing. + * + *

Thread-safe. {@link Ai} calls both methods from + * {@code runTaskAsynchronously}, so two players asking at once means two + * threads in here at the same time, and {@link #history} writes while + * it reads — it prunes expired entries. Unsynchronised that is not a stale + * read but a corrupt map: a concurrent resize can lose entries or spin, and + * pruning under another thread's write throws {@code ConcurrentModification} + * straight up into the player's answer. Guarded by {@link #byPlayer}'s own + * monitor, consistently with {@link Wiki}; nothing slow happens under the lock. + */ +final class Conversations { + + /** + * A hard ceiling on remembered players. Expiry alone does not bound this: + * entries are only pruned for the player who asks, so someone who asks once + * and logs off would sit in the map until the next restart. Least-recently + * used goes first, and at this size a busy server loses nothing that is + * still a live conversation. + */ + static final int MAX_PLAYERS = 200; + + private static final long NANOS_PER_MINUTE = 60_000_000_000L; + + /** {@code at} is a {@link System#nanoTime} reading; see {@link #expired}. */ + private record Entry(MiniMax.Turn turn, long at) { + } + + private final int maxTurns; + private final long windowNanos; + /** + * Access-ordered so eviction drops the player who has been quiet longest + * rather than whoever happened to ask first. + */ + private final Map> byPlayer = new LinkedHashMap<>(16, 0.75f, true); + + Conversations(int maxExchanges, int windowMinutes) { + // Each exchange is two turns, and the product is computed in long + // arithmetic: a config of Integer.MAX_VALUE would overflow an int + // multiply to a negative bound, which silently disables memory instead + // of honouring the (absurd) request. + this.maxTurns = (int) Math.min(2L * Math.max(0, maxExchanges), Integer.MAX_VALUE); + long minutes = Math.max(0, windowMinutes); + // Saturating rather than wrapping: an overflowed window would come out + // negative and expire every entry on the spot, turning "remember for a + // very long time" into "remember nothing". + this.windowNanos = minutes > Long.MAX_VALUE / NANOS_PER_MINUTE + ? Long.MAX_VALUE + : minutes * NANOS_PER_MINUTE; + } + + void remember(UUID player, String question, String answer) { + if (maxTurns == 0) { + // Short-circuit before touching the map. Falling through would add + // two turns, drop both, and leave an empty deque behind — a memory + // configured off would still grow one map entry per player. + return; + } + long now = System.nanoTime(); + synchronized (byPlayer) { + Deque entries = byPlayer.computeIfAbsent(player, key -> new ArrayDeque<>()); + entries.addLast(new Entry(new MiniMax.Turn("user", question), now)); + entries.addLast(new Entry(new MiniMax.Turn("assistant", answer), now)); + while (entries.size() > maxTurns) { + entries.removeFirst(); + } + while (byPlayer.size() > MAX_PLAYERS) { + byPlayer.remove(byPlayer.keySet().iterator().next()); + } + } + } + + /** Recent messages still inside the window, oldest first. */ + List history(UUID player) { + long now = System.nanoTime(); + List out = new ArrayList<>(); + synchronized (byPlayer) { + Deque entries = byPlayer.get(player); + if (entries == null) { + return out; + } + entries.removeIf(entry -> expired(entry, now)); + if (entries.isEmpty()) { + // Do not leave the key behind: a player whose memory has run + // out is indistinguishable from one who never asked. + byPlayer.remove(player); + return out; + } + for (Entry entry : entries) { + out.add(entry.turn()); + } + } + return out; + } + + void forget(UUID player) { + synchronized (byPlayer) { + byPlayer.remove(player); + } + } + + /** How many players are currently remembered. For tests. */ + int size() { + synchronized (byPlayer) { + return byPlayer.size(); + } + } + + /** + * Elapsed time compared as a difference, and against {@link System#nanoTime} + * rather than the wall clock. Two reasons. The difference form is the only + * correct way to compare nanoTime readings, which are allowed to be negative + * and to wrap. And nanoTime is monotonic: an NTP step backwards mid-session + * would leave wall-clock entries stamped in the future, so {@code now - at} + * would go negative and the entry would never expire — memory that outlives + * its window and answers a fresh question with an hour-old one. + * + *

{@code >=} and not {@code >}, so a window of zero expires everything + * immediately instead of depending on whether two calls landed in the same + * clock tick. + */ + private boolean expired(Entry entry, long now) { + return now - entry.at() >= windowNanos; + } +} diff --git a/src/test/java/dev/marcospaulo/canalhandia/ConversationsTest.java b/src/test/java/dev/marcospaulo/canalhandia/ConversationsTest.java new file mode 100644 index 0000000..4d96eab --- /dev/null +++ b/src/test/java/dev/marcospaulo/canalhandia/ConversationsTest.java @@ -0,0 +1,146 @@ +package dev.marcospaulo.canalhandia; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; + +class ConversationsTest { + + @Test + void keepsRecentExchangesForFollowUps() { + Conversations c = new Conversations(3, 10); + UUID id = UUID.randomUUID(); + c.remember(id, "onde acho diamante?", "abaixo do Y 16"); + assertEquals(2, c.history(id).size()); + assertEquals("onde acho diamante?", c.history(id).get(0).content()); + } + + @Test + void dropsOldestBeyondLimit() { + Conversations c = new Conversations(2, 10); + UUID id = UUID.randomUUID(); + c.remember(id, "q1", "a1"); + c.remember(id, "q2", "a2"); + c.remember(id, "q3", "a3"); + assertEquals(4, c.history(id).size()); + assertEquals("q2", c.history(id).get(0).content()); + } + + @Test + void expiresAfterTheWindow() { + Conversations c = new Conversations(3, 0); + UUID id = UUID.randomUUID(); + c.remember(id, "q", "a"); + assertTrue(c.history(id).isEmpty()); + } + + @Test + void playersDoNotShareHistory() { + Conversations c = new Conversations(3, 10); + UUID a = UUID.randomUUID(); + UUID b = UUID.randomUUID(); + c.remember(a, "q", "resposta de A"); + assertTrue(c.history(b).isEmpty()); + } + + /** Roles alternate user/assistant, which is what the API expects. */ + @Test + void alternatesUserAndAssistantRoles() { + Conversations c = new Conversations(3, 10); + UUID id = UUID.randomUUID(); + c.remember(id, "q", "a"); + List history = c.history(id); + assertEquals("user", history.get(0).role()); + assertEquals("assistant", history.get(1).role()); + assertEquals("a", history.get(1).content()); + } + + /** A memory of zero exchanges must remember nothing at all, not even a key. */ + @Test + void zeroExchangesRemembersNothing() { + Conversations c = new Conversations(0, 10); + UUID id = UUID.randomUUID(); + c.remember(id, "q", "a"); + assertTrue(c.history(id).isEmpty()); + assertEquals(0, c.size()); + } + + @Test + void forgetDropsOnlyThatPlayer() { + Conversations c = new Conversations(3, 10); + UUID a = UUID.randomUUID(); + UUID b = UUID.randomUUID(); + c.remember(a, "q", "a"); + c.remember(b, "q", "b"); + c.forget(a); + assertTrue(c.history(a).isEmpty()); + assertEquals(2, c.history(b).size()); + } + + /** Expired entries must not leave the player behind as a permanent key. */ + @Test + void expiryEvictsThePlayerEntirely() { + Conversations c = new Conversations(3, 0); + UUID id = UUID.randomUUID(); + c.remember(id, "q", "a"); + c.history(id); + assertEquals(0, c.size()); + } + + /** A busy server must not accumulate a map entry per player forever. */ + @Test + void boundsTheNumberOfRememberedPlayers() { + Conversations c = new Conversations(3, 10); + for (int i = 0; i < Conversations.MAX_PLAYERS + 50; i++) { + c.remember(UUID.randomUUID(), "q", "a"); + } + assertEquals(Conversations.MAX_PLAYERS, c.size()); + } + + /** + * {@link Ai} calls this from {@code runTaskAsynchronously}, so several + * players asking at once means several threads in here at the same time. + * Unsynchronised, this corrupts the map or spins forever on a resize. + */ + @Test + void survivesConcurrentUse() throws Exception { + Conversations c = new Conversations(3, 10); + int threads = 8; + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(threads); + AtomicReference failure = new AtomicReference<>(); + List workers = new ArrayList<>(); + for (int t = 0; t < threads; t++) { + UUID id = UUID.randomUUID(); + Thread worker = new Thread(() -> { + try { + start.await(); + for (int i = 0; i < 400; i++) { + c.remember(id, "q" + i, "a" + i); + assertFalse(c.history(id).isEmpty()); + c.history(UUID.randomUUID()); + } + } catch (Throwable e) { + failure.compareAndSet(null, e); + } finally { + done.countDown(); + } + }); + workers.add(worker); + worker.start(); + } + start.countDown(); + assertTrue(done.await(30, TimeUnit.SECONDS), "threads travaram"); + for (Thread worker : workers) { + worker.join(); + } + assertNull(failure.get(), String.valueOf(failure.get())); + } +}