@rigour-labs/core 6.3.0 → 6.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,7 +9,7 @@ const home = vi.hoisted(() => {
9
9
  return p.join(o.tmpdir(), `rigour-models-${process.pid}-${Date.now()}`);
10
10
  });
11
11
  vi.mock('../storage/db.js', () => ({ RIGOUR_DIR: home }));
12
- const { downloadModel, getCachedModel } = await import('./model-manager.js');
12
+ const { checkForUpdates, downloadModel, getCachedModel, normalizeModelVersion } = await import('./model-manager.js');
13
13
  const { FALLBACK_MODELS, MODELS } = await import('./types.js');
14
14
  const modelsDir = path.join(home, 'models');
15
15
  const PAYLOAD = Buffer.from('gguf-model-bytes');
@@ -22,7 +22,7 @@ describe('downloadModel', () => {
22
22
  beforeEach(async () => {
23
23
  await fs.remove(home);
24
24
  // A fresh version check: no network call to Hugging Face.
25
- await fs.outputJson(path.join(modelsDir, '.latest_version.json'), { version: '2.0.0', checkedAt: new Date().toISOString() });
25
+ await fs.outputJson(path.join(modelsDir, '.latest_version.json'), { format: 2, version: '5', checkedAt: new Date().toISOString() });
26
26
  });
27
27
  afterAll(async () => { await fs.remove(home); });
28
28
  it('falls back to the stock model when the fine-tuned model is unavailable, and says so', async () => {
@@ -39,6 +39,19 @@ describe('downloadModel', () => {
39
39
  const meta = await fs.readJson(`${modelPath}.meta.json`);
40
40
  expect(meta).toMatchObject({ sha256: PAYLOAD_SHA, sizeBytes: PAYLOAD.length, sourceUrl: FALLBACK_MODELS.lite.url });
41
41
  });
42
+ it('retries the fine-tuned model once a day when only the stock fallback is cached', async () => {
43
+ const failing = (url) => (url.includes('rigour-labs/') ? unauthorized : ok(PAYLOAD, PAYLOAD_SHA));
44
+ const stockPath = await downloadModel('lite', undefined, failing);
45
+ const requested = [];
46
+ const again = await downloadModel('lite', undefined, (url) => { requested.push(url); return unauthorized; });
47
+ expect(requested).toEqual([]); // failed less than a day ago: keep the fallback, no request
48
+ expect(again).toBe(stockPath);
49
+ await fs.writeJson(path.join(modelsDir, '.fine-tuned-unavailable-lite.json'), { failedAt: '2000-01-01T00:00:00Z', url: MODELS.lite.url });
50
+ const fineTuned = await downloadModel('lite', undefined, (url) => { requested.push(url); return ok(PAYLOAD, PAYLOAD_SHA); });
51
+ expect(requested).toEqual([MODELS.lite.url]);
52
+ expect(path.basename(fineTuned)).toBe(MODELS.lite.filename);
53
+ expect(await fs.pathExists(path.join(modelsDir, '.fine-tuned-unavailable-lite.json'))).toBe(false);
54
+ });
42
55
  it('rejects a checksum mismatch and deletes the partial download', async () => {
43
56
  const get = () => ok(PAYLOAD, 'f'.repeat(64));
44
57
  await expect(downloadModel('lite', undefined, get)).rejects.toThrow('Checksum mismatch');
@@ -65,9 +78,48 @@ describe('getCachedModel', () => {
65
78
  const cached = await getCachedModel('deep');
66
79
  expect(cached).toMatchObject({ fallback: true, info: { filename: FALLBACK_MODELS.deep.filename } });
67
80
  });
81
+ it('accepts a verified fine-tuned file smaller than the size estimate', async () => {
82
+ await cacheFile(MODELS.lite.filename, 397_807_360); // the published v5 lite model
83
+ expect(await getCachedModel('lite')).toMatchObject({ fallback: false, info: { filename: MODELS.lite.filename } });
84
+ });
85
+ it('accepts a file verified within the same millisecond it was written', async () => {
86
+ const file = path.join(modelsDir, FALLBACK_MODELS.deep.filename);
87
+ await cacheFile(FALLBACK_MODELS.deep.filename, 10);
88
+ const written = new Date('2026-01-01T00:00:00.000Z');
89
+ await fs.utimes(file, written, written.getTime() / 1000 + 0.0007); // mtime 0.7 ms into the same millisecond
90
+ await fs.writeJson(`${file}.meta.json`, { sha256: 'a'.repeat(64), sizeBytes: 10, verifiedAt: written.toISOString(), sourceUrl: 'https://x' });
91
+ expect(await getCachedModel('deep')).toMatchObject({ fallback: true });
92
+ });
93
+ it('rejects a file modified after it was verified', async () => {
94
+ const file = path.join(modelsDir, FALLBACK_MODELS.deep.filename);
95
+ await cacheFile(FALLBACK_MODELS.deep.filename, 10);
96
+ await fs.writeJson(`${file}.meta.json`, { sha256: 'a'.repeat(64), sizeBytes: 10, verifiedAt: '2000-01-01T00:00:00.000Z', sourceUrl: 'https://x' });
97
+ expect(await getCachedModel('deep')).toBeNull();
98
+ });
68
99
  it('ignores a file without verified metadata', async () => {
69
100
  await fs.ensureDir(modelsDir);
70
101
  await fs.writeFile(path.join(modelsDir, FALLBACK_MODELS.deep.filename), 'partial');
71
102
  expect(await getCachedModel('deep')).toBeNull();
72
103
  });
73
104
  });
105
+ describe('model versions', () => {
106
+ beforeEach(async () => { await fs.remove(home); vi.unstubAllGlobals(); });
107
+ it('keeps published version strings as they are', () => {
108
+ expect(normalizeModelVersion(5)).toBe('5');
109
+ expect(normalizeModelVersion('5')).toBe('5');
110
+ expect(normalizeModelVersion('v2.1.0')).toBe('2.1.0');
111
+ expect(normalizeModelVersion('5.0')).toBeNull();
112
+ expect(normalizeModelVersion({})).toBeNull();
113
+ });
114
+ it('names the published v5 repositories by default', () => {
115
+ expect(MODELS.lite.url).toBe('https://huggingface.co/rigour-labs/rigour-lite-v5-gguf/resolve/main/rigour-lite-v5-q4_k_m.gguf');
116
+ expect(MODELS.deep.url).toBe('https://huggingface.co/rigour-labs/rigour-deep-v5-gguf/resolve/main/rigour-deep-v5-q4_k_m.gguf');
117
+ });
118
+ it('ignores a version cache from the old SemVer conversion and re-reads the published version', async () => {
119
+ await fs.outputJson(path.join(modelsDir, '.latest_version.json'), { version: '5.0.0', checkedAt: new Date().toISOString() });
120
+ vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => ({ version: 5 }) })));
121
+ expect(await checkForUpdates()).toBe('5');
122
+ expect(await fs.readJson(path.join(modelsDir, '.latest_version.json'))).toMatchObject({ format: 2, version: '5' });
123
+ expect(MODELS.deep.filename).toBe('rigour-deep-v5-q4_k_m.gguf');
124
+ });
125
+ });
@@ -26,6 +26,12 @@ export declare function getModelPath(tier: ModelTier): string;
26
26
  * Get model info for a tier.
27
27
  */
28
28
  export declare function getModelInfo(tier: ModelTier): ModelInfo;
29
+ /**
30
+ * The version exactly as published. Integer versions (the RLAIF pipeline
31
+ * before SemVer) are published as `v5`, SemVer versions as `v2.1.0`; either
32
+ * way the string goes into the repository and file names unchanged.
33
+ */
34
+ export declare function normalizeModelVersion(raw: unknown): string | null;
29
35
  /**
30
36
  * Check HuggingFace for a newer model version (like antivirus signature updates).
31
37
  * Reads latest_version.json from the RLAIF dataset repo. Non-blocking — if the
@@ -8,6 +8,7 @@ import { createHash } from 'crypto';
8
8
  import { RIGOUR_DIR } from '../storage/db.js';
9
9
  import { downloadToFile } from './http-download.js';
10
10
  import { MODELS, FALLBACK_MODELS, VERSION_CHECK_URL, BUNDLED_MODEL_VERSION, updateModelVersion } from './types.js';
11
+ import { Logger } from '../utils/logger.js';
11
12
  const MODELS_DIR = path.join(RIGOUR_DIR, 'models');
12
13
  const VERSION_CACHE_PATH = path.join(MODELS_DIR, '.latest_version.json');
13
14
  const VERSION_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // Check once per day
@@ -63,13 +64,14 @@ async function isFileCached(model) {
63
64
  const metadata = await readModelMeta(model.filename);
64
65
  if (!metadata)
65
66
  return false;
67
+ // The metadata is written only after the checksum passed, so its exact size is
68
+ // the completeness check; ModelInfo sizes are display estimates that differ by version.
66
69
  const stat = await fs.stat(modelPath);
67
- const tolerance = model.sizeBytes * 0.1;
68
- if (stat.size <= model.sizeBytes - tolerance)
69
- return false;
70
70
  if (metadata.sizeBytes !== stat.size)
71
71
  return false;
72
- if (new Date(metadata.verifiedAt).getTime() < stat.mtimeMs)
72
+ // verifiedAt has millisecond precision and mtimeMs a sub-millisecond fraction: compare
73
+ // whole milliseconds, or a file verified within the same millisecond looks modified.
74
+ if (new Date(metadata.verifiedAt).getTime() < Math.floor(stat.mtimeMs))
73
75
  return false;
74
76
  return true;
75
77
  }
@@ -161,6 +163,27 @@ async function downloadFromUrl(model, onProgress, get) {
161
163
  onProgress?.(`Model ${model.name} ready`, 100);
162
164
  return destPath;
163
165
  }
166
+ /**
167
+ * Version cache format. Format 1 turned a published integer version into
168
+ * SemVer ("5" -> "5.0.0"), which names repositories that were never published,
169
+ * so those caches are ignored.
170
+ */
171
+ const VERSION_CACHE_FORMAT = 2;
172
+ /** How long a failed fine-tuned download keeps the stock fallback before retrying. */
173
+ const FINE_TUNED_RETRY_MS = 24 * 60 * 60 * 1000;
174
+ /**
175
+ * The version exactly as published. Integer versions (the RLAIF pipeline
176
+ * before SemVer) are published as `v5`, SemVer versions as `v2.1.0`; either
177
+ * way the string goes into the repository and file names unchanged.
178
+ */
179
+ export function normalizeModelVersion(raw) {
180
+ if (typeof raw === 'number' && Number.isInteger(raw) && raw > 0)
181
+ return String(raw);
182
+ if (typeof raw !== 'string')
183
+ return null;
184
+ const value = raw.trim().replace(/^v/, '');
185
+ return /^\d+$/.test(value) || /^\d+\.\d+\.\d+$/.test(value) ? value : null;
186
+ }
164
187
  /**
165
188
  * Check HuggingFace for a newer model version (like antivirus signature updates).
166
189
  * Reads latest_version.json from the RLAIF dataset repo. Non-blocking — if the
@@ -175,8 +198,8 @@ export async function checkForUpdates(onProgress) {
175
198
  if (await fs.pathExists(VERSION_CACHE_PATH)) {
176
199
  const cached = await fs.readJson(VERSION_CACHE_PATH);
177
200
  const age = Date.now() - new Date(cached.checkedAt).getTime();
178
- if (age < VERSION_CHECK_INTERVAL_MS && cached.version) {
179
- const v = String(cached.version);
201
+ const v = cached.format === VERSION_CACHE_FORMAT ? normalizeModelVersion(cached.version) : null;
202
+ if (age < VERSION_CHECK_INTERVAL_MS && v) {
180
203
  updateModelVersion(v);
181
204
  return v;
182
205
  }
@@ -193,13 +216,10 @@ export async function checkForUpdates(onProgress) {
193
216
  clearTimeout(timeout);
194
217
  if (response.ok) {
195
218
  const data = await response.json();
196
- // Handle both legacy integer versions (5 → "5.0.0") and SemVer strings ("2.0.0")
197
- let rawVersion = data.version ?? BUNDLED_MODEL_VERSION;
198
- const latestVersion = typeof rawVersion === 'number'
199
- ? `${rawVersion}.0.0`
200
- : String(rawVersion);
219
+ const latestVersion = normalizeModelVersion(data.version) ?? BUNDLED_MODEL_VERSION;
201
220
  // Cache the result locally
202
221
  await fs.writeJson(VERSION_CACHE_PATH, {
222
+ format: VERSION_CACHE_FORMAT,
203
223
  version: latestVersion,
204
224
  checkedAt: new Date().toISOString(),
205
225
  source: 'huggingface',
@@ -226,22 +246,42 @@ export async function downloadModel(tier, onProgress, get) {
226
246
  // Check for newer model version (non-blocking, cached 24h)
227
247
  await checkForUpdates(onProgress);
228
248
  const cached = await getCachedModel(tier);
229
- if (cached) {
249
+ if (cached && !cached.fallback) {
230
250
  onProgress?.(`Model ${cached.info.name} already cached`, 100);
231
251
  return cached.path;
232
252
  }
253
+ // Only the stock fallback is cached: try the fine-tuned model again, at most once a day,
254
+ // so one failed download does not pin a user to the stock model forever.
255
+ if (cached && !(await fineTunedRetryDue(tier)))
256
+ return cached.path;
233
257
  const model = MODELS[tier];
234
258
  onProgress?.(`Downloading ${model.name} (${model.sizeHuman})...`, 0);
235
259
  try {
236
- return await downloadFromUrl(model, onProgress, get);
260
+ const downloaded = await downloadFromUrl(model, onProgress, get);
261
+ await fs.remove(retryMarker(tier)).catch(() => { });
262
+ return downloaded;
237
263
  }
238
264
  catch (error) {
239
265
  const fallback = FALLBACK_MODELS[tier];
240
- if (fallback && fallback.url !== model.url) {
241
- onProgress?.(`Fine-tuned model unavailable (${error?.message ?? 'unknown error'}); using stock ${fallback.name}`, 0);
242
- return downloadFromUrl(fallback, onProgress, get);
243
- }
244
- throw error;
266
+ if (!fallback || fallback.url === model.url)
267
+ throw error;
268
+ const reason = error?.message ?? 'unknown error';
269
+ Logger.warn(`Fine-tuned model ${model.name} could not be downloaded from ${model.url} (${reason}); using the stock ${fallback.name}. Deep findings will be less accurate.`);
270
+ onProgress?.(`Fine-tuned model unavailable (${reason}); using stock ${fallback.name}`, 0);
271
+ await fs.writeJson(retryMarker(tier), { failedAt: new Date().toISOString(), url: model.url, reason }).catch(() => { });
272
+ return cached?.path ?? downloadFromUrl(fallback, onProgress, get);
273
+ }
274
+ }
275
+ function retryMarker(tier) {
276
+ return path.join(MODELS_DIR, `.fine-tuned-unavailable-${tier}.json`);
277
+ }
278
+ async function fineTunedRetryDue(tier) {
279
+ try {
280
+ const marker = await fs.readJson(retryMarker(tier));
281
+ return Date.now() - new Date(marker.failedAt).getTime() >= FINE_TUNED_RETRY_MS || marker.url !== MODELS[tier].url;
282
+ }
283
+ catch {
284
+ return true;
245
285
  }
246
286
  }
247
287
  /**
@@ -86,12 +86,16 @@ export interface ModelInfo {
86
86
  * At startup, model-manager checks HF for the latest version and downloads
87
87
  * it automatically (like antivirus signature updates).
88
88
  *
89
- * Version format: SemVer (MAJOR.MINOR.PATCH)
89
+ * The fallback is the last version actually published: v5, from the RLAIF
90
+ * pipeline's integer versions. SemVer versions (MAJOR.MINOR.PATCH) are
91
+ * published under their SemVer names.
92
+ *
93
+ * Version format: SemVer (MAJOR.MINOR.PATCH), or a legacy integer
90
94
  * - MAJOR: Training data format change, base model change, pipeline architecture
91
95
  * - MINOR: New training repos, updated dataset, hyperparameter improvements
92
96
  * - PATCH: Bug fixes, retraining with same data/format
93
97
  */
94
- export declare const BUNDLED_MODEL_VERSION = "2.0.0";
98
+ export declare const BUNDLED_MODEL_VERSION = "5";
95
99
  /** HuggingFace dataset repo where latest_version.json lives */
96
100
  export declare const VERSION_CHECK_URL = "https://huggingface.co/datasets/rigour-labs/rigour-rlaif-data/resolve/main/latest_version.json";
97
101
  /** Build model info for a given tier and version */
@@ -5,19 +5,23 @@
5
5
  * At startup, model-manager checks HF for the latest version and downloads
6
6
  * it automatically (like antivirus signature updates).
7
7
  *
8
- * Version format: SemVer (MAJOR.MINOR.PATCH)
8
+ * The fallback is the last version actually published: v5, from the RLAIF
9
+ * pipeline's integer versions. SemVer versions (MAJOR.MINOR.PATCH) are
10
+ * published under their SemVer names.
11
+ *
12
+ * Version format: SemVer (MAJOR.MINOR.PATCH), or a legacy integer
9
13
  * - MAJOR: Training data format change, base model change, pipeline architecture
10
14
  * - MINOR: New training repos, updated dataset, hyperparameter improvements
11
15
  * - PATCH: Bug fixes, retraining with same data/format
12
16
  */
13
- export const BUNDLED_MODEL_VERSION = '2.0.0';
17
+ export const BUNDLED_MODEL_VERSION = '5';
14
18
  /** HuggingFace dataset repo where latest_version.json lives */
15
19
  export const VERSION_CHECK_URL = 'https://huggingface.co/datasets/rigour-labs/rigour-rlaif-data/resolve/main/latest_version.json';
16
20
  /** Build model info for a given tier and version */
17
21
  export function buildModelInfo(tier, version) {
18
22
  const meta = {
19
23
  deep: { base: 'Qwen2.5-Coder-1.5B', size: 900_000_000, sizeH: '900MB' },
20
- lite: { base: 'Qwen2.5-Coder-0.5B', size: 500_000_000, sizeH: '500MB' },
24
+ lite: { base: 'Qwen2.5-Coder-0.5B', size: 400_000_000, sizeH: '400MB' },
21
25
  legacy: { base: 'Qwen2.5-Coder-0.5B', size: 350_000_000, sizeH: '350MB' },
22
26
  };
23
27
  const m = meta[tier];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rigour-labs/core",
3
- "version": "6.3.0",
3
+ "version": "6.3.1",
4
4
  "description": "AI-native quality gate engine with local Bayesian learning. AST analysis, drift detection, Fix Packet generation, and agent self-healing across TypeScript, JavaScript, Python, Go, Ruby, and C#.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://rigour.run",
@@ -67,11 +67,11 @@
67
67
  "pg": "^8.16.3",
68
68
  "sqlite3": "^5.1.7",
69
69
  "openai": "^4.104.0",
70
- "@rigour-labs/brain-darwin-arm64": "6.3.0",
71
- "@rigour-labs/brain-darwin-x64": "6.3.0",
72
- "@rigour-labs/brain-linux-arm64": "6.3.0",
73
- "@rigour-labs/brain-win-x64": "6.3.0",
74
- "@rigour-labs/brain-linux-x64": "6.3.0"
70
+ "@rigour-labs/brain-darwin-x64": "6.3.1",
71
+ "@rigour-labs/brain-darwin-arm64": "6.3.1",
72
+ "@rigour-labs/brain-linux-x64": "6.3.1",
73
+ "@rigour-labs/brain-linux-arm64": "6.3.1",
74
+ "@rigour-labs/brain-win-x64": "6.3.1"
75
75
  },
76
76
  "devDependencies": {
77
77
  "@types/fs-extra": "^11.0.4",