Files
ruvnet__ruflo/v3/@claude-flow/cli/__tests__/q-state-encoder-2239.test.ts
ruv 455152da03 fix(intelligence): 4-issue bug cluster — Opus 4.8 + uptime + MCP orphan + Q-encoder
Fixes four user-reported bugs from the same triage round, each with a
regression test and the minimal honest fix:

#2232 (Opus 4.8 / silent-downgrade): bump `opus` alias to claude-opus-4-8
(keep `opus-4.7` reachable), and route `executeAgentTask` through the
existing `resolveAnthropicModel` so literal claude-* ids stop silently
collapsing to Sonnet 4.6 via the bare MODEL_MAP||DEFAULT fold. Widens the
agent-tools alias whitelist, JSON-schema enum, and updates the statusline /
settings defaults.

#2235(B) (stale system_status.uptime): read live `process.uptime()` instead
of the persisted metrics.startTime, which was the file's creation timestamp
and reported ~8.8-day uptime on a freshly-spawned server. (Note: part A —
agentdb db-unified legacy SQLite never engaging better-sqlite3 — is in the
bundled agentdb package and is upstream-only.)

#2234 (MCP server orphan / ~1 GB leak): add a parent-death watchdog to
`ruflo mcp start` that exits cleanly when the process is reparented to
launchd/init (ppid===1) — i.e. Claude Code has exited. Stops ~50 MB
orphans accumulating across restarts and a stale orphan winning the stdio
handshake.

#2239 (Q-state encoder discards keyword block): replace the 31-bit
truncating fold (which silently dropped features 0–31, collapsing
keyword-distinct tasks to one Q-state) with a 32-bit FNV-1a fold over the
full quantized vector. Bump encoder version to 2 in the persisted model;
loadModel resets the Q-table on a v1→v2 mismatch (keeps stats/epsilon).

Tests: 17 new regression tests across all four issues; full CLI suite
62 files / 2104 passed / 46 skipped / 0 failed.

Closes #2232, #2234, #2239. Closes #2235 (B); #2235 (A) tracked upstream.

Co-Authored-By: RuFlo <ruv@ruv.net>
2026-05-29 18:30:28 -04:00

89 lines
3.6 KiB
TypeScript

// Regression tests for #2239 — Q-state encoder discarded the keyword block.
//
// The previous 31-bit truncating fold collapsed four routing-keyword-distinct
// tasks (`test/review/architect/optimize the new module now`) to ONE Q-state.
// After the FNV-1a fold and encoder-version=2 migration, keyword-distinct
// tasks must produce distinct state keys, and a persisted v1 model must
// reset its Q-table on load (keys are not comparable across encoder versions).
import { describe, it, expect } from 'vitest';
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { QLearningRouter } from '../src/ruvector/q-learning-router.js';
const r = new QLearningRouter();
const stateKey = (s: string) => r.getStateKey(s);
describe('Q-state encoder (#2239)', () => {
it('produces distinct state keys for the reporter\'s six keyword-distinct tasks', () => {
const tasks = [
'implement the new module now',
'test the new module now',
'review the new module now',
'architect the new module now',
'research the new module now',
'optimize the new module now',
];
const keys = tasks.map(stateKey);
expect(new Set(keys).size).toBe(tasks.length); // no collapse to one Q-state
});
it('still produces the SAME key for the SAME input (deterministic)', () => {
expect(stateKey('test this module')).toBe(stateKey('test this module'));
});
it('produces a `fstate_…` shape', () => {
expect(stateKey('refactor the API')).toMatch(/^fstate_[0-9a-z]+$/);
});
});
describe('encoder-version migration (#2239)', () => {
it('resets the Q-table on encoder-version mismatch (v1 persisted → v2 current)', async () => {
const dir = mkdtempSync(join(tmpdir(), 'q-mig-'));
const modelPath = join(dir, 'q-learning-model.json');
// A v1 persisted model with one fake state — v1 keys are not portable.
writeFileSync(modelPath, JSON.stringify({
version: '1.0.0',
// encoderVersion missing → treated as 1
config: { numActions: 8 },
qTable: { 'fstate_legacy': { qValues: [99, 99, 99, 99, 99, 99, 99, 99], visits: 50 } },
stats: { stepCount: 100, updateCount: 50, avgTDError: 0.1, epsilon: 0.5 },
metadata: { savedAt: new Date(0).toISOString(), totalExperiences: 100 },
}));
const router = new QLearningRouter({ modelPath });
const loaded = await router.loadModel(modelPath);
expect(loaded).toBe(true);
// The legacy state must NOT survive (its key was computed by the old fold).
const exported = router.export();
expect(Object.keys(exported)).not.toContain('fstate_legacy');
rmSync(dir, { recursive: true, force: true });
});
it('a v2 persisted model loads its Q-table intact', async () => {
const dir = mkdtempSync(join(tmpdir(), 'q-v2-'));
const modelPath = join(dir, 'q-learning-model.json');
// Compute a real v2 key from the live encoder.
const seed = new QLearningRouter();
const realKey = seed.getStateKey('keep this entry across restart');
writeFileSync(modelPath, JSON.stringify({
version: '1.0.0',
encoderVersion: 2,
config: { numActions: 8 },
qTable: { [realKey]: { qValues: [1, 2, 3, 4, 5, 6, 7, 8], visits: 7 } },
stats: { stepCount: 0, updateCount: 0, avgTDError: 0, epsilon: 0.5 },
metadata: { savedAt: new Date().toISOString(), totalExperiences: 0 },
}));
const router = new QLearningRouter({ modelPath });
expect(await router.loadModel(modelPath)).toBe(true);
expect(Object.keys(router.export())).toContain(realKey);
rmSync(dir, { recursive: true, force: true });
});
});