Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions src/__tests__/swapReputationScorer.test.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
const fs = require("fs");
const os = require("os");
const path = require("path");

const {
calculateReputationScore,
batchCalculateReputation,
recencyWeight,
scoreTier,
STARTING_SCORE,
MemoryReputationStore,
FileReputationStore,
persistReputationScore,
getPersistedReputationScore,
} = require("../reputation/swapReputationScorer");

const NOW = new Date("2024-06-01T00:00:00.000Z").getTime();
Expand Down Expand Up @@ -106,3 +114,80 @@ describe("batchCalculateReputation", () => {
expect(() => batchCalculateReputation([])).toThrow(TypeError);
});
});

describe("persistence", () => {
describe("MemoryReputationStore", () => {
test("persists and retrieves a score within the same process", () => {
const store = new MemoryReputationStore();
const history = makeHistory(20);
persistReputationScore({ participantId: "p1", history }, store, NOW);

const persisted = getPersistedReputationScore("p1", store);
expect(persisted).not.toBeNull();
expect(persisted.participantId).toBe("p1");
expect(persisted.updatedAt).toBe(new Date(NOW).toISOString());
});

test("returns null for an unknown participant", () => {
const store = new MemoryReputationStore();
expect(getPersistedReputationScore("nobody", store)).toBeNull();
});
});

describe("FileReputationStore", () => {
let filePath;

beforeEach(() => {
filePath = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), "reputation-store-")),
"scores.json"
);
});

afterEach(() => {
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
});

test("persists a score across process restarts", () => {
const history = makeHistory(20);

// "Before restart": compute and persist with one store instance.
const storeBefore = new FileReputationStore(filePath);
const written = persistReputationScore({ participantId: "p1", history }, storeBefore, NOW);

// "After restart": a brand-new store instance pointed at the same
// file, with no shared in-memory state, must still see the score.
const storeAfter = new FileReputationStore(filePath);
const reloaded = getPersistedReputationScore("p1", storeAfter);

expect(reloaded).toEqual(written);
expect(reloaded.score).toBe(written.score);
});

test("getAll returns every persisted participant after reload", () => {
const storeBefore = new FileReputationStore(filePath);
persistReputationScore({ participantId: "p1", history: makeHistory(20) }, storeBefore, NOW);
persistReputationScore({ participantId: "p2", history: makeHistory(3, "cancelled") }, storeBefore, NOW);

const storeAfter = new FileReputationStore(filePath);
const ids = storeAfter.getAll().map((r) => r.participantId).sort();
expect(ids).toEqual(["p1", "p2"]);
});

test("returns null for an unknown participant without creating the file early", () => {
const store = new FileReputationStore(filePath);
expect(getPersistedReputationScore("nobody", store)).toBeNull();
expect(fs.existsSync(filePath)).toBe(false);
});
});

test("persistReputationScore throws without a valid store", () => {
expect(() =>
persistReputationScore({ participantId: "p1", history: [] }, {})
).toThrow(TypeError);
});

test("getPersistedReputationScore throws without a valid store", () => {
expect(() => getPersistedReputationScore("p1", {})).toThrow(TypeError);
});
});
126 changes: 126 additions & 0 deletions src/reputation/swapReputationScorer.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,35 @@
* - Tenure bonus (account age in days)
* - Volume bonus (total swap count)
* - Cancellation penalty (cancelled swaps)
*
* Persistence — Issue #878
* ──────────────────────────────────────
* `calculateReputationScore` / `batchCalculateReputation` above are pure
* functions: given a history they return a score, nothing is written down.
* That's fine for the API/contract layer's own request-scoped caching
* (`api-server/src/cache.rs`, key prefix `reputation:`, TTL-based), but it
* means there is no durable record a score ever existed once that cache
* entry expires or the process restarts.
*
* This module fills that gap with a small `ReputationStore` interface —
* `get(participantId)`, `set(participantId, record)`, `getAll()` — so the
* scoring logic stays decoupled from *where* scores live:
*
* - `MemoryReputationStore` — process-local Map, not durable. Default
* choice for tests and short-lived scripts.
* - `FileReputationStore` — scores serialized to a JSON file on disk.
* Durable across process restarts; intended as the default backend for
* single-instance deployments/tooling that don't have a real DB handy.
*
* A production, multi-instance deployment should back this interface with
* the API server's shared store instead (e.g. the Redis-backed cache in
* `api-server/src/cache.rs`, or a proper DB table) by implementing the
* same three methods — nothing above this layer needs to change.
*/

const fs = require("fs");
const path = require("path");

const STARTING_SCORE = 500;
const MAX_SCORE = 1000;
const MIN_SCORE = 0;
Expand Down Expand Up @@ -129,6 +156,101 @@ function batchCalculateReputation(inputs, nowMs = Date.now()) {
.sort((a, b) => b.score - a.score);
}

/**
* In-memory reputation store. Not durable — data is lost when the process
* exits. Useful as the default in tests and short-lived scripts, and as a
* reference implementation of the `ReputationStore` interface.
*/
class MemoryReputationStore {
constructor() {
this._records = new Map();
}

get(participantId) {
return this._records.get(participantId) ?? null;
}

set(participantId, record) {
this._records.set(participantId, record);
}

getAll() {
return Array.from(this._records.values());
}
}

/**
* File-backed reputation store. Scores are serialized as JSON to disk, so
* they survive process restarts — the store re-reads from disk on every
* call rather than caching in memory, which keeps it correct if multiple
* short-lived processes share the same file.
*/
class FileReputationStore {
constructor(filePath) {
if (!filePath) throw new TypeError("filePath is required.");
this.filePath = filePath;
}

_readAll() {
try {
const raw = fs.readFileSync(this.filePath, "utf8");
return JSON.parse(raw);
} catch (err) {
if (err.code === "ENOENT") return {};
throw err;
}
}

_writeAll(records) {
const dir = path.dirname(this.filePath);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(this.filePath, JSON.stringify(records, null, 2));
}

get(participantId) {
const records = this._readAll();
return records[participantId] ?? null;
}

set(participantId, record) {
const records = this._readAll();
records[participantId] = record;
this._writeAll(records);
}

getAll() {
return Object.values(this._readAll());
}
}

/**
* Calculate a participant's reputation score and persist it to `store`.
*
* @param {object} input - same shape as `calculateReputationScore`.
* @param {{get, set, getAll}} store - a `ReputationStore` implementation.
* @returns {object} the calculated result (same shape as
* `calculateReputationScore`), plus `updatedAt`.
*/
function persistReputationScore(input, store, nowMs = Date.now()) {
if (!store || typeof store.set !== "function")
throw new TypeError("store must implement the ReputationStore interface.");

const result = calculateReputationScore(input, nowMs);
const record = { ...result, updatedAt: new Date(nowMs).toISOString() };
store.set(result.participantId, record);
return record;
}

/**
* Look up a previously persisted reputation score. Returns `null` if the
* participant has no persisted record.
*/
function getPersistedReputationScore(participantId, store) {
if (!store || typeof store.get !== "function")
throw new TypeError("store must implement the ReputationStore interface.");
return store.get(participantId);
}

module.exports = {
calculateReputationScore,
batchCalculateReputation,
Expand All @@ -137,4 +259,8 @@ module.exports = {
STARTING_SCORE,
MAX_SCORE,
MIN_SCORE,
MemoryReputationStore,
FileReputationStore,
persistReputationScore,
getPersistedReputationScore,
};