diff --git a/packages/verifier-runtime/src/cache.test.ts b/packages/verifier-runtime/src/cache.test.ts new file mode 100644 index 0000000..9de6de5 --- /dev/null +++ b/packages/verifier-runtime/src/cache.test.ts @@ -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); +}); diff --git a/packages/verifier-runtime/src/cache.ts b/packages/verifier-runtime/src/cache.ts new file mode 100644 index 0000000..66d5147 --- /dev/null +++ b/packages/verifier-runtime/src/cache.ts @@ -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(); + 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(); diff --git a/packages/verifier-runtime/src/index.ts b/packages/verifier-runtime/src/index.ts index 1b6c74a..99db05b 100644 --- a/packages/verifier-runtime/src/index.ts +++ b/packages/verifier-runtime/src/index.ts @@ -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; diff --git a/packages/verifier-runtime/src/verify.test.ts b/packages/verifier-runtime/src/verify.test.ts new file mode 100644 index 0000000..b810b84 --- /dev/null +++ b/packages/verifier-runtime/src/verify.test.ts @@ -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); +}); diff --git a/packages/verifier-runtime/src/verify.ts b/packages/verifier-runtime/src/verify.ts index 5faf336..8befe8f 100644 --- a/packages/verifier-runtime/src/verify.ts +++ b/packages/verifier-runtime/src/verify.ts @@ -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'; @@ -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; @@ -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; } /**