bulltrackers-module 1.0.281 → 1.0.282

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.
@@ -1,34 +1,32 @@
1
1
  /**
2
2
  * @fileoverview Build Reporter & Auto-Runner.
3
3
  * Generates a "Pre-Flight" report of what the computation system WILL do.
4
- * REFACTORED: Strict 5-category reporting with date-based exclusion logic.
5
- * UPDATED: Replaced Batch Writes with Parallel Writes to prevent DEADLINE_EXCEEDED timeouts.
6
- * FIXED: Ensures 'latest' pointer updates even if detail writes fail.
7
- * UPDATED (IDEA 1): Added Dependency Impact Analysis ("Blast Radius").
4
+ * UPGRADED: Implements Behavioral Hashing (SimHash) to detect Cosmetic vs Logic changes.
5
+ * OPTIMIZED: Caches SimHashes and actively updates status for Stable items to prevent re-runs.
8
6
  */
9
7
 
10
8
  const { analyzeDateExecution } = require('../WorkflowOrchestrator');
11
- const { fetchComputationStatus } = require('../persistence/StatusRepository');
9
+ const { fetchComputationStatus, updateComputationStatus } = require('../persistence/StatusRepository');
12
10
  const { normalizeName, getExpectedDateStrings, DEFINITIVE_EARLIEST_DATES } = require('../utils/utils');
13
11
  const { checkRootDataAvailability } = require('../data/AvailabilityChecker');
12
+ const SimRunner = require('../simulation/SimRunner');
14
13
  const pLimit = require('p-limit');
15
14
  const path = require('path');
16
15
  const packageJson = require(path.join(__dirname, '..', '..', '..', 'package.json'));
17
16
  const packageVersion = packageJson.version;
18
17
 
18
+ // Persistent Registry for SimHashes (so Workers don't have to recalc)
19
+ const SIMHASH_REGISTRY_COLLECTION = 'system_simhash_registry';
20
+
19
21
  /**
20
- * Helper: Determines if a calculation should be excluded from the report
21
- * because the date is prior to the earliest possible data existence.
22
+ * Helper: Determines if a calculation should be excluded from the report.
22
23
  */
23
24
  function isDateBeforeAvailability(dateStr, calcManifest) {
24
25
  const targetDate = new Date(dateStr + 'T00:00:00Z');
25
26
  const deps = calcManifest.rootDataDependencies || [];
26
-
27
- // If no data dependencies, it's always valid (e.g., pure math)
28
27
  if (deps.length === 0) return false;
29
28
 
30
29
  for (const dep of deps) {
31
- // Map dependency name to start date
32
30
  let startDate = null;
33
31
  if (dep === 'portfolio') startDate = DEFINITIVE_EARLIEST_DATES.portfolio;
34
32
  else if (dep === 'history') startDate = DEFINITIVE_EARLIEST_DATES.history;
@@ -36,7 +34,6 @@ function isDateBeforeAvailability(dateStr, calcManifest) {
36
34
  else if (dep === 'insights') startDate = DEFINITIVE_EARLIEST_DATES.insights;
37
35
  else if (dep === 'price') startDate = DEFINITIVE_EARLIEST_DATES.price;
38
36
 
39
- // If we have a start date and the target is BEFORE it, exclude this calc.
40
37
  if (startDate && targetDate < startDate) { return true; }
41
38
  }
42
39
  return false;
@@ -44,17 +41,14 @@ function isDateBeforeAvailability(dateStr, calcManifest) {
44
41
 
45
42
  /**
46
43
  * Helper: Calculates the transitive closure of dependents (Blast Radius).
47
- * Returns the count of direct and total cascading dependents.
48
44
  */
49
45
  function calculateBlastRadius(targetCalcName, reverseGraph) {
50
46
  const impactSet = new Set();
51
47
  const queue = [targetCalcName];
52
48
 
53
- // BFS Traversal
54
49
  while(queue.length > 0) {
55
50
  const current = queue.shift();
56
51
  const dependents = reverseGraph.get(current) || [];
57
-
58
52
  dependents.forEach(child => {
59
53
  if (!impactSet.has(child)) {
60
54
  impactSet.add(child);
@@ -66,46 +60,96 @@ function calculateBlastRadius(targetCalcName, reverseGraph) {
66
60
  return {
67
61
  directDependents: (reverseGraph.get(targetCalcName) || []).length,
68
62
  totalCascadingDependents: impactSet.size,
69
- affectedCalculations: Array.from(impactSet).slice(0, 50) // Cap list size for storage safety
63
+ affectedCalculations: Array.from(impactSet).slice(0, 50)
70
64
  };
71
65
  }
72
66
 
67
+ /**
68
+ * [NEW] Helper: Runs SimHash check with Caching and Registry Persistence.
69
+ */
70
+ async function verifyBehavioralStability(candidates, manifestMap, dailyStatus, logger, simHashCache, db) {
71
+ const trueReRuns = [];
72
+ const stableUpdates = [];
73
+
74
+ // Limit concurrency for simulations
75
+ const limit = pLimit(10);
76
+
77
+ const checks = candidates.map(item => limit(async () => {
78
+ try {
79
+ const manifest = manifestMap.get(item.name);
80
+ const stored = dailyStatus[item.name];
81
+
82
+ if (!stored || !stored.simHash || !manifest) {
83
+ trueReRuns.push(item);
84
+ return;
85
+ }
86
+
87
+ // 1. Check Cache first (Avoid re-simulating the same code for 100 different dates)
88
+ let newSimHash = simHashCache.get(manifest.hash);
89
+
90
+ // 2. If Miss, Run Simulation & Persist to Registry
91
+ if (!newSimHash) {
92
+ newSimHash = await SimRunner.run(manifest, manifestMap);
93
+ simHashCache.set(manifest.hash, newSimHash);
94
+
95
+ // Write to Registry so Production Workers can find it without running SimRunner
96
+ // Fire-and-forget write to reduce latency
97
+ db.collection(SIMHASH_REGISTRY_COLLECTION).doc(manifest.hash).set({
98
+ simHash: newSimHash,
99
+ createdAt: new Date(),
100
+ calcName: manifest.name
101
+ }).catch(err => logger.log('WARN', `Failed to write SimHash registry for ${manifest.name}: ${err.message}`));
102
+ }
103
+
104
+ // 3. Compare
105
+ if (newSimHash === stored.simHash) {
106
+ // BEHAVIORAL MATCH: Code changed, but output is identical.
107
+ stableUpdates.push({
108
+ ...item,
109
+ reason: "Code Updated (Logic Stable)",
110
+ simHash: newSimHash, // New SimHash (same as old)
111
+ newHash: manifest.hash // New Code Hash
112
+ });
113
+ } else {
114
+ // BEHAVIORAL MISMATCH: Logic changed.
115
+ trueReRuns.push({
116
+ ...item,
117
+ reason: item.reason + ` [SimHash Mismatch]`,
118
+ oldSimHash: stored.simHash,
119
+ newSimHash: newSimHash
120
+ });
121
+ }
122
+ } catch (e) {
123
+ logger.log('WARN', `[BuildReporter] SimHash check failed for ${item.name}: ${e.message}`);
124
+ trueReRuns.push(item);
125
+ }
126
+ }));
127
+
128
+ await Promise.all(checks);
129
+ return { trueReRuns, stableUpdates };
130
+ }
131
+
73
132
  /**
74
133
  * AUTO-RUN ENTRY POINT
75
- * Uses transactional locking to prevent race conditions.
76
134
  */
77
135
  async function ensureBuildReport(config, dependencies, manifest) {
78
136
  const { db, logger } = dependencies;
79
137
  const now = new Date();
80
- const buildId = `v${packageVersion}_${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}_${String(now.getHours()).padStart(2,'0')}-${String(now.getMinutes()).padStart(2,'0')}-${String(now.getSeconds()).padStart(2,'0')}`;
81
-
82
- // Lock document specific to this version
138
+ const buildId = `v${packageVersion}_${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}_${String(now.getHours()).padStart(2,'0')}`;
83
139
  const lockRef = db.collection('computation_build_records').doc(`init_lock_v${packageVersion}`);
84
140
 
85
141
  try {
86
- // Transaction: "Hey I am deploying" check
87
142
  const shouldRun = await db.runTransaction(async (t) => {
88
- const doc = await t.get(lockRef);
89
-
90
- if (doc.exists) { return false; } // Someone else beat us to it
91
-
92
- // Claim the lock
93
- t.set(lockRef, {
94
- status: 'IN_PROGRESS',
95
- startedAt: new Date(),
96
- workerId: process.env.K_REVISION || 'unknown',
97
- buildId: buildId
98
- });
143
+ const doc = await t.get(lockRef);
144
+ if (doc.exists) { return false; }
145
+ t.set(lockRef, { status: 'IN_PROGRESS', startedAt: new Date(), buildId: buildId });
99
146
  return true;
100
147
  });
101
148
 
102
- if (!shouldRun) { logger.log('INFO', `[BuildReporter] 🔒 Report for v${packageVersion} is already being generated (Locked). Skipping.`); return; }
149
+ if (!shouldRun) { logger.log('INFO', `[BuildReporter] 🔒 Report for v${packageVersion} locked. Skipping.`); return; }
103
150
 
104
- logger.log('INFO', `[BuildReporter] 🚀 Lock Acquired. Running Pre-flight Report for v${packageVersion}...`);
105
-
151
+ logger.log('INFO', `[BuildReporter] 🚀 Running Pre-flight Report for v${packageVersion}...`);
106
152
  await generateBuildReport(config, dependencies, manifest, 90, buildId);
107
-
108
- // Optional: Update lock to completed (fire-and-forget update)
109
153
  lockRef.update({ status: 'COMPLETED', completedAt: new Date() }).catch(() => {});
110
154
 
111
155
  } catch (e) {
@@ -114,7 +158,7 @@ async function ensureBuildReport(config, dependencies, manifest) {
114
158
  }
115
159
 
116
160
  /**
117
- * Generates the report and saves to Firestore.
161
+ * Generates the report, writes to Firestore, AND FIXES STABLE UPDATES.
118
162
  */
119
163
  async function generateBuildReport(config, dependencies, manifest, daysBack = 90, customBuildId = null) {
120
164
  const { db, logger } = dependencies;
@@ -129,7 +173,9 @@ async function generateBuildReport(config, dependencies, manifest, daysBack = 90
129
173
  const datesToCheck = getExpectedDateStrings(startDate, today);
130
174
  const manifestMap = new Map(manifest.map(c => [normalizeName(c.name), c]));
131
175
 
132
- // [IDEA 1] Build Reverse Dependency Graph (Parent -> Children)
176
+ // [OPTIMIZATION] Cache SimHashes across dates so we only calculate once per code version
177
+ const simHashCache = new Map();
178
+
133
179
  const reverseGraph = new Map();
134
180
  manifest.forEach(c => {
135
181
  const parentName = normalizeName(c.name);
@@ -142,17 +188,9 @@ async function generateBuildReport(config, dependencies, manifest, daysBack = 90
142
188
  }
143
189
  });
144
190
 
145
- // Main Report Header
146
- const reportHeader = {
147
- buildId,
148
- packageVersion: packageVersion,
149
- generatedAt: new Date().toISOString(),
150
- summary: {},
151
- _sharded: true
152
- };
191
+ const reportHeader = { buildId, packageVersion, generatedAt: new Date().toISOString(), summary: {}, _sharded: true };
153
192
 
154
- let totalRun = 0;
155
- let totalReRun = 0;
193
+ let totalRun = 0, totalReRun = 0, totalStable = 0;
156
194
  const detailWrites = [];
157
195
 
158
196
  const limit = pLimit(20);
@@ -178,93 +216,89 @@ async function generateBuildReport(config, dependencies, manifest, daysBack = 90
178
216
  const dailyStatus = results[0];
179
217
  const availability = results[1];
180
218
  const prevDailyStatus = (prevDateStr && results[2]) ? results[2] : (prevDateStr ? {} : null);
181
- const rootDataStatus = availability ? availability.status : { hasPortfolio: false, hasHistory: false, hasSocial: false, hasInsights: false, hasPrices: false };
219
+ const rootDataStatus = availability ? availability.status : { hasPortfolio: false, hasHistory: false };
182
220
 
183
221
  const analysis = analyzeDateExecution(dateStr, manifest, rootDataStatus, dailyStatus, manifestMap, prevDailyStatus);
184
222
 
185
- // ---------------------------------------------------------
186
- // STRICT 5-CATEGORY MAPPING
187
- // ---------------------------------------------------------
188
223
  const dateSummary = {
189
- run: [], // New / No Hash / "Runnable"
190
- rerun: [], // Hash Mismatch / Category Migration
191
- blocked: [], // Missing Data (Today) / Dependency Missing
192
- impossible: [], // Missing Data (Historical) / Impossible Dependency
193
- uptodate: [], // Hash Match (Previously "Skipped")
194
-
195
- // Metadata for Verification
196
- meta: {
197
- totalIncluded: 0,
198
- totalExpected: 0,
199
- match: false
200
- }
224
+ run: [], rerun: [], stable: [], blocked: [], impossible: [], uptodate: [],
225
+ meta: { totalIncluded: 0, totalExpected: 0, match: false }
201
226
  };
202
227
 
203
- // Calculate Expected Count (Computations in manifest that exist for this date)
204
228
  const expectedCount = manifest.filter(c => !isDateBeforeAvailability(dateStr, c)).length;
205
229
  dateSummary.meta.totalExpected = expectedCount;
206
230
 
207
- // Helper to push only if date is valid for this specific calc
208
231
  const pushIfValid = (targetArray, item, extraReason = null) => {
209
232
  const calcManifest = manifestMap.get(item.name);
210
- if (calcManifest && isDateBeforeAvailability(dateStr, calcManifest)) {
211
- return; // EXCLUDED: Date is before data exists
212
- }
233
+ if (calcManifest && isDateBeforeAvailability(dateStr, calcManifest)) return;
213
234
 
214
235
  const entry = {
215
- name: item.name,
236
+ name: item.name,
216
237
  reason: item.reason || extraReason,
217
- pass: calcManifest ? calcManifest.pass : '?'
238
+ pass: calcManifest ? calcManifest.pass : '?'
218
239
  };
219
-
220
- // [IDEA 1] If this is a Re-Run, calculate Blast Radius
221
240
  if (targetArray === dateSummary.rerun) {
222
241
  entry.impact = calculateBlastRadius(item.name, reverseGraph);
223
242
  }
224
-
225
243
  targetArray.push(entry);
226
244
  };
227
245
 
228
- // 1. RUN (New)
246
+ // 1. RUN
229
247
  analysis.runnable.forEach(item => pushIfValid(dateSummary.run, item, "New Calculation"));
230
248
 
231
- // 2. RE-RUN (Hash Mismatch)
232
- analysis.reRuns.forEach(item => pushIfValid(dateSummary.rerun, item, "Hash Mismatch"));
249
+ // 2. RE-RUN & STABLE Analysis (SimHash Integration)
250
+ if (analysis.reRuns.length > 0) {
251
+ // Pass simHashCache and db for registry writes
252
+ const { trueReRuns, stableUpdates } = await verifyBehavioralStability(analysis.reRuns, manifestMap, dailyStatus, logger, simHashCache, db);
253
+
254
+ trueReRuns.forEach(item => pushIfValid(dateSummary.rerun, item, "Logic Changed"));
255
+ stableUpdates.forEach(item => pushIfValid(dateSummary.stable, item, "Cosmetic Change"));
256
+
257
+ // [CRITICAL FIX] "Fix the Blast Radius"
258
+ // If updates are STABLE, we update the status NOW.
259
+ // This implies: Code Hash changes, but Sim Hash stays same.
260
+ // The Dispatcher will see the new Code Hash in status matches the Manifest, so it won't dispatch.
261
+ if (stableUpdates.length > 0) {
262
+ const updatesPayload = {};
263
+ for (const stable of stableUpdates) {
264
+ const m = manifestMap.get(stable.name);
265
+ // We preserve the *existing* resultHash because the logic is proven stable.
266
+ // We update the 'hash' to the NEW code hash.
267
+ if (m && dailyStatus[stable.name]) {
268
+ updatesPayload[stable.name] = {
269
+ hash: m.hash, // New Code Hash
270
+ simHash: stable.simHash, // Same Sim Hash
271
+ resultHash: dailyStatus[stable.name].resultHash, // Same Result Hash
272
+ dependencyResultHashes: dailyStatus[stable.name].dependencyResultHashes || {},
273
+ category: m.category,
274
+ composition: m.composition,
275
+ lastUpdated: new Date()
276
+ };
277
+ }
278
+ }
279
+ // Perform the "Fix"
280
+ if (Object.keys(updatesPayload).length > 0) {
281
+ await updateComputationStatus(dateStr, updatesPayload, config, dependencies);
282
+ logger.log('INFO', `[BuildReporter] 🩹 Fixed ${Object.keys(updatesPayload).length} stable items for ${dateStr}. They will NOT re-run.`);
283
+ }
284
+ }
285
+ }
233
286
 
234
- // 3. BLOCKED (Temporary Issues)
287
+ // 3. BLOCKED / IMPOSSIBLE / UPTODATE
235
288
  analysis.blocked.forEach(item => pushIfValid(dateSummary.blocked, item));
236
289
  analysis.failedDependency.forEach(item => pushIfValid(dateSummary.blocked, item, "Dependency Missing"));
237
-
238
- // 4. IMPOSSIBLE (Permanent Issues)
239
290
  analysis.impossible.forEach(item => pushIfValid(dateSummary.impossible, item));
240
-
241
- // 5. UP-TO-DATE (Previously "Skipped")
242
291
  analysis.skipped.forEach(item => pushIfValid(dateSummary.uptodate, item, "Up To Date"));
243
292
 
244
- // Calculate Included Count
245
- const includedCount = dateSummary.run.length +
246
- dateSummary.rerun.length +
247
- dateSummary.blocked.length +
248
- dateSummary.impossible.length +
249
- dateSummary.uptodate.length;
250
-
293
+ // Meta stats
294
+ const includedCount = dateSummary.run.length + dateSummary.rerun.length + dateSummary.stable.length +
295
+ dateSummary.blocked.length + dateSummary.impossible.length + dateSummary.uptodate.length;
251
296
  dateSummary.meta.totalIncluded = includedCount;
252
- dateSummary.meta.match = (includedCount === expectedCount);
253
-
254
- if (!dateSummary.meta.match) {
255
- logger.log('WARN', `[BuildReporter] ⚠️ Mismatch on ${dateStr}: Expected ${expectedCount} but got ${includedCount}.`);
256
- }
297
+ dateSummary.meta.match = (includedCount === expectedCount);
257
298
 
258
- // QUEUE THE WRITE (Don't write yet)
259
- const detailRef = db.collection('computation_build_records').doc(buildId).collection('details').doc(dateStr);
260
- detailWrites.push({
261
- ref: detailRef,
262
- data: dateSummary
263
- });
299
+ detailWrites.push({ ref: db.collection('computation_build_records').doc(buildId).collection('details').doc(dateStr), data: dateSummary });
264
300
 
265
- return {
266
- stats: { run: dateSummary.run.length, rerun: dateSummary.rerun.length }
267
- };
301
+ return { stats: { run: dateSummary.run.length, rerun: dateSummary.rerun.length, stable: dateSummary.stable.length } };
268
302
 
269
303
  } catch (err) {
270
304
  logger.log('ERROR', `[BuildReporter] Error analyzing date ${dateStr}: ${err.message}`);
@@ -274,69 +308,21 @@ async function generateBuildReport(config, dependencies, manifest, daysBack = 90
274
308
 
275
309
  const results = await Promise.all(processingPromises);
276
310
 
277
- results.forEach(res => {
278
- if (res) {
279
- totalRun += res.stats.run;
280
- totalReRun += res.stats.rerun;
281
- }
282
- });
283
-
284
- reportHeader.summary = {
285
- totalReRuns: totalReRun,
286
- totalNew: totalRun,
287
- scanRange: `${datesToCheck[0]} to ${datesToCheck[datesToCheck.length-1]}`
288
- };
311
+ results.forEach(res => { if (res) { totalRun += res.stats.run; totalReRun += res.stats.rerun; totalStable += res.stats.stable; } });
289
312
 
290
- // 1. Write the main report header (Specific Version)
291
- const reportRef = db.collection('computation_build_records').doc(buildId);
292
- await reportRef.set(reportHeader);
313
+ reportHeader.summary = { totalReRuns: totalReRun, totalNew: totalRun, totalStable: totalStable, scanRange: `${datesToCheck[0]} to ${datesToCheck[datesToCheck.length-1]}` };
293
314
 
294
- // 2. Write Details (Protected & Parallelized)
295
- // FIX: Using parallel individual writes instead of Batch to avoid DEADLINE_EXCEEDED
296
- let detailsSuccess = true;
297
- if (detailWrites.length > 0) {
298
- logger.log('INFO', `[BuildReporter] Writing ${detailWrites.length} detail records (Parallel Strategy)...`);
299
-
300
- try {
301
- // Concurrency limit of 15 to be safe
302
- const writeLimit = pLimit(15);
303
- const writePromises = detailWrites.map(w => writeLimit(() =>
304
- w.ref.set(w.data).catch(e => {
305
- logger.log('WARN', `[BuildReporter] Failed to write detail for ${w.ref.path}: ${e.message}`);
306
- throw e;
307
- })
308
- ));
309
-
310
- await Promise.all(writePromises);
311
- logger.log('INFO', `[BuildReporter] Successfully wrote all detail records.`);
312
-
313
- } catch (detailErr) {
314
- detailsSuccess = false;
315
- logger.log('ERROR', `[BuildReporter] ⚠️ Failed to write some details. Report Header is preserved.`, detailErr);
316
- }
317
- }
315
+ await db.collection('computation_build_records').doc(buildId).set(reportHeader);
316
+
317
+ // Parallel write details
318
+ const writeLimit = pLimit(15);
319
+ await Promise.all(detailWrites.map(w => writeLimit(() => w.ref.set(w.data))));
318
320
 
319
- // 3. Update 'latest' pointer
320
- // This runs regardless of detail write success/failure
321
- const latestMetadata = {
322
- ...reportHeader,
323
- note: detailsSuccess
324
- ? "Latest build report pointer (See subcollection for details)."
325
- : "Latest build report pointer (WARNING: Partial detail records due to write error)."
326
- };
321
+ await db.collection('computation_build_records').doc('latest').set({ ...reportHeader, note: "Latest build report pointer." });
327
322
 
328
- try {
329
- await db.collection('computation_build_records').doc('latest').set(latestMetadata);
330
- logger.log('SUCCESS', `[BuildReporter] Report ${buildId} saved. Re-runs: ${totalReRun}, New: ${totalRun}. Pointer Updated.`);
331
- } catch (pointerErr) {
332
- logger.log('FATAL', `[BuildReporter] Failed to update 'latest' pointer!`, pointerErr);
333
- }
323
+ logger.log('SUCCESS', `[BuildReporter] Report ${buildId} saved. Re-runs: ${totalReRun}, Stable (Fixed): ${totalStable}, New: ${totalRun}.`);
334
324
 
335
- return {
336
- success: true,
337
- reportId: buildId,
338
- summary: reportHeader.summary
339
- };
325
+ return { success: true, reportId: buildId, summary: reportHeader.summary };
340
326
  }
341
327
 
342
328
  module.exports = { ensureBuildReport, generateBuildReport };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bulltrackers-module",
3
- "version": "1.0.281",
3
+ "version": "1.0.282",
4
4
  "description": "Helper Functions for Bulltrackers.",
5
5
  "main": "index.js",
6
6
  "files": [