Skip to content
Closed
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
111 changes: 111 additions & 0 deletions packages/verifier-runtime/src/cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { expect, test } from 'bun:test';
import { VerifyResultCache, verifierCache } from './cache.js';
import type { VerifyInput } from './verify.js';

test('VerifyResultCache constructor creates an empty cache', () => {
const cache = new VerifyResultCache();
expect(cache.size).toBe(0);
});

test('VerifyResultCache.set and .get round-trip', () => {
const cache = new VerifyResultCache();
const result = { passed: true, expected_hash: 'abc', actual_hash: 'abc' };
const input: VerifyInput = {
taskId: 'test-normalize-1',
input: { a: 1 },
output: { a: 1 },
constraints: { sort_keys: false, strip_nulls: false, flatten: null },
};
const key = VerifyResultCache.key(input);
cache.set(key, result);
expect(cache.get(key)).toEqual(result);
expect(cache.size).toBe(1);
});

test('VerifyResultCache returns undefined for cache miss', () => {
const cache = new VerifyResultCache();
expect(cache.get('nonexistent')).toBeUndefined();
});

test('VerifyResultCache.key is deterministic for same input', () => {
const input1: VerifyInput = {
taskId: 'test-normalize-1',
input: { a: 1, b: 2 },
output: { a: 1, b: 2 },
constraints: { sort_keys: true, strip_nulls: false, flatten: null },
};
const input2: VerifyInput = {
taskId: 'test-normalize-1',
input: { a: 1, b: 2 },
output: { a: 1, b: 2 },
constraints: { sort_keys: true, strip_nulls: false, flatten: null },
};
expect(VerifyResultCache.key(input1)).toBe(VerifyResultCache.key(input2));
});

test('VerifyResultCache.key differs for different outputs', () => {
const input1: VerifyInput = {
taskId: 'test-normalize-1',
input: { a: 1 },
output: { a: 1 },
constraints: { sort_keys: false, strip_nulls: false, flatten: null },
};
const input2: VerifyInput = {
taskId: 'test-normalize-1',
input: { a: 1 },
output: { a: 2 },
constraints: { sort_keys: false, strip_nulls: false, flatten: null },
};
expect(VerifyResultCache.key(input1)).not.toBe(VerifyResultCache.key(input2));
});

test('VerifyResultCache evicts oldest entry when over max size', () => {
const cache = new VerifyResultCache(3);
const results = [
{ passed: true, expected_hash: '1', actual_hash: '1' },
{ passed: true, expected_hash: '2', actual_hash: '2' },
{ passed: true, expected_hash: '3', actual_hash: '3' },
{ passed: true, expected_hash: '4', actual_hash: '4' },
];
for (let i = 0; i < results.length; i++) {
const input: VerifyInput = {
taskId: `test-${i}`,
input: { n: i },
output: { n: i },
constraints: { sort_keys: false, strip_nulls: false, flatten: null },
};
cache.set(VerifyResultCache.key(input), results[i]);
}
// Fourth insert should evict the first entry.
const firstInput: VerifyInput = {
taskId: 'test-0',
input: { n: 0 },
output: { n: 0 },
constraints: { sort_keys: false, strip_nulls: false, flatten: null },
};
expect(cache.get(VerifyResultCache.key(firstInput))).toBeUndefined();
expect(cache.size).toBe(3);
});

test('VerifyResultCache.clear empties the cache', () => {
const cache = new VerifyResultCache();
const input: VerifyInput = {
taskId: 'test-clear',
input: { x: 1 },
output: { x: 1 },
constraints: { sort_keys: false, strip_nulls: false, flatten: null },
};
cache.set(VerifyResultCache.key(input), {
passed: true,
expected_hash: 'h',
actual_hash: 'h',
});
expect(cache.size).toBe(1);
cache.clear();
expect(cache.size).toBe(0);
});

test('global verifierCache singleton exists', () => {
expect(verifierCache).toBeDefined();
expect(verifierCache).toBeInstanceOf(VerifyResultCache);
});
82 changes: 82 additions & 0 deletions packages/verifier-runtime/src/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* In-memory LRU cache for verifier results keyed by content hash.
*
* This cache stores (task_spec + solver_answer) → VerifyResult mappings
* so that repeated identical verification calls are short-circuited.
* The cache is a singleton shared across the entire process.
*
* @packageDocumentation
*/

import type { VerifyInput, VerifyResult } from './verify.js';
import { sha256Hex, stableStringify } from './crypto.js';

/** Default maximum number of entries in the LRU cache. */
const DEFAULT_MAX_SIZE = 1000;

/**
* A minimal LRU cache implemented with a Map (iteration order = insertion order
* in modern JS engines). When the cache exceeds `maxSize`, the oldest entry
* (first inserted) is evicted.
*/
export class VerifyResultCache {
private readonly _map = new Map<string, VerifyResult>();
private readonly _maxSize: number;

constructor(maxSize = DEFAULT_MAX_SIZE) {
this._maxSize = maxSize;
}

/** Compute a deterministic cache key from the verify input. */
static key(input: VerifyInput): string {
// The key incorporates the task ID, operation type, input, output, and constraints
// so that different solver answers produce different cache entries.
return sha256Hex(
stableStringify({
taskId: input.taskId,
operationType: input.operationType,
input: input.input,
output: input.output,
constraints: input.constraints,
}),
);
}

/** Look up a cached result. Returns `undefined` on a cache miss. */
get(key: string): VerifyResult | undefined {
const value = this._map.get(key);
if (value !== undefined) {
// Refresh — delete and re-insert to maintain LRU order.
this._map.delete(key);
this._map.set(key, value);
}
return value;
}

/** Store a result in the cache, evicting the oldest entry if necessary. */
set(key: string, value: VerifyResult): void {
if (this._map.has(key)) {
this._map.delete(key);
} else if (this._map.size >= this._maxSize) {
// Evict the first (oldest) entry.
const oldest = this._map.keys().next();
if (!oldest.done) {
this._map.delete(oldest.value);
}
}
this._map.set(key, value);
}

/** Number of entries currently cached. */
get size(): number {
return this._map.size;
}

/** Clear all cached entries. */
clear(): void {
this._map.clear();
}
}

/** The singleton cache instance used by the verifier runtime. */
export const verifierCache = new VerifyResultCache();
1 change: 1 addition & 0 deletions packages/verifier-runtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export {
type VerifyResult,
} from './verify.js';
export { sha256Hex, sha256OfString, shortHash, stableStringify } from './crypto.js';
export { VerifyResultCache, verifierCache } from './cache.js';

export interface VerifierPackage {
id: string;
Expand Down
77 changes: 77 additions & 0 deletions packages/verifier-runtime/src/verify.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { expect, test } from 'bun:test';
import { verify, expectedHashFor } from './verify.js';
import { VerifyResultCache } from './cache.js';

test('verify returns passed=true for correct normalize output', () => {
const result = verify({
taskId: 'test-normalize-1',
input: { b: 1, a: 2 },
output: { a: 2, b: 1 },
constraints: { sort_keys: false, strip_nulls: false, flatten: null },
});
expect(result.passed).toBe(true);
});

test('verify returns passed=false for incorrect output', () => {
const result = verify({
taskId: 'test-normalize-1',
input: { a: 1 },
output: { a: 2 },
constraints: { sort_keys: false, strip_nulls: false, flatten: null },
});
expect(result.passed).toBe(false);
expect(result.failure_reason).toBeDefined();
});

test('verify uses cache when provided', () => {
const cache = new VerifyResultCache(10);
const input = {
taskId: 'test-normalize-cache',
input: { x: 10 },
output: { x: 10 },
constraints: { sort_keys: false, strip_nulls: false, flatten: null },
};

// First call computes and caches.
const first = verify(input, cache);
expect(first.passed).toBe(true);
expect(cache.size).toBe(1);

// Second call should return cached result.
const second = verify(input, cache);
expect(second).toEqual(first);
// Size should not increase.
expect(cache.size).toBe(1);
});

test('verify caching works with the global singleton', () => {
// Use the global cache by omitting the cache argument.
const input = {
taskId: 'test-global-cache',
input: { z: 99 },
output: { z: 99 },
constraints: { sort_keys: true, strip_nulls: false, flatten: null },
};

const result = verify(input);
expect(result.passed).toBe(true);
// The global cache now has this result.
});

test('expectedHashFor returns a deterministic string', () => {
const hash1 = expectedHashFor({ a: 1 }, { sort_keys: false, strip_nulls: false, flatten: null });
const hash2 = expectedHashFor({ a: 1 }, { sort_keys: false, strip_nulls: false, flatten: null });
expect(hash1).toBe(hash2);
expect(typeof hash1).toBe('string');
expect(hash1.length).toBe(64); // SHA-256 hex
});

test('expectedHashFor with taskId infers operation', () => {
const hash = expectedHashFor(
{ source: { a: 1 }, target: { a: 2 } },
{ max_depth: 10, array_indices: true, format: 'ops' },
'test-diff-operation',
);
expect(typeof hash).toBe('string');
expect(hash.length).toBe(64);
});
18 changes: 16 additions & 2 deletions packages/verifier-runtime/src/verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
parseNormalizeConstraints,
parseSchemaMigrationConstraints,
} from '@fresharena/faep-schema';
import { verifierCache, VerifyResultCache } from './cache.js';
import { sha256Hex } from './crypto.js';
import { apply, diff } from './diff_patch.js';
import { merge } from './merge.js';
Expand Down Expand Up @@ -33,8 +34,18 @@ export interface VerifyResult {
* For normalize, diff, patch, merge, and migrate operations, a submission passes
* iff its structural hash equals the reference implementation's structural hash.
* Object key order is irrelevant (see `stableStringify`); array order is significant.
*
* Results are cached by content hash so that repeated identical verification
* calls short-circuit the computation.
*/
export function verify(input: VerifyInput): VerifyResult {
export function verify(input: VerifyInput, cache?: VerifyResultCache): VerifyResult {
const cacheInstance = cache ?? verifierCache;
const cacheKey = VerifyResultCache.key(input);
const cached = cacheInstance.get(cacheKey);
if (cached !== undefined) {
return cached;
}

const operationType = input.operationType || inferOperationType(input.taskId);

let expected: unknown;
Expand Down Expand Up @@ -79,7 +90,10 @@ export function verify(input: VerifyInput): VerifyResult {
actual_hash: actualHash,
};

return passed ? base : { ...base, failure_reason: failureReason };
const result: VerifyResult = passed ? base : { ...base, failure_reason: failureReason };

cacheInstance.set(cacheKey, result);
return result;
}

/**
Expand Down
Loading