feat: short per-player conversation memory
Guarded by a monitor: Ai calls this from runTaskAsynchronously and history() prunes while it reads, so an unsynchronised map would throw ConcurrentModification into a player's answer or corrupt itself on resize. Expiry compares nanoTime differences rather than wall-clock instants, so a zero window expires deterministically and an NTP step backwards cannot leave entries stamped in the future and unexpirable. Player count is capped LRU, since expiry only prunes players who ask again.
This commit is contained in:
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <p>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} <em>writes</em> 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<UUID, Deque<Entry>> 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<Entry> 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<MiniMax.Turn> history(UUID player) {
|
||||||
|
long now = System.nanoTime();
|
||||||
|
List<MiniMax.Turn> out = new ArrayList<>();
|
||||||
|
synchronized (byPlayer) {
|
||||||
|
Deque<Entry> 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.
|
||||||
|
*
|
||||||
|
* <p>{@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<MiniMax.Turn> 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<Throwable> failure = new AtomicReference<>();
|
||||||
|
List<Thread> 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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user