Skip to content

Commit 6b1ea49

Browse files
authored
feat(forecast): add report continuity history (koala73#1788)
* feat(forecast): cluster situations in world state * feat(forecast): add report continuity history * fix(forecast): stabilize report continuity matching
1 parent c1f8aa5 commit 6b1ea49

File tree

2 files changed

+317
-3
lines changed

2 files changed

+317
-3
lines changed

scripts/seed-forecasts.mjs

Lines changed: 189 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const TRACE_LATEST_KEY = 'forecast:trace:latest:v1';
2020
const TRACE_RUNS_KEY = 'forecast:trace:runs:v1';
2121
const TRACE_RUNS_MAX = 50;
2222
const TRACE_REDIS_TTL_SECONDS = 60 * 24 * 60 * 60;
23+
const WORLD_STATE_HISTORY_LIMIT = 6;
2324
const PUBLISH_MIN_PROBABILITY = 0;
2425
const PANEL_MIN_PROBABILITY = 0.1;
2526
const ENRICHMENT_COMBINED_MAX = 3;
@@ -2322,7 +2323,6 @@ function computeSituationSimilarity(currentCluster, priorCluster) {
23222323
overlapCount(currentCluster.forecastIds || [], priorCluster.forecastIds || []) * 0.5
23232324
);
23242325
}
2325-
23262326
function buildSituationClusters(predictions) {
23272327
const clusters = [];
23282328

@@ -2480,6 +2480,128 @@ function buildSituationSummary(situationClusters, situationContinuity) {
24802480
};
24812481
}
24822482

2483+
function summarizeWorldStateHistory(priorWorldStates = []) {
2484+
return priorWorldStates
2485+
.filter(Boolean)
2486+
.slice(0, WORLD_STATE_HISTORY_LIMIT)
2487+
.map((state) => ({
2488+
generatedAt: state.generatedAt,
2489+
generatedAtIso: state.generatedAtIso,
2490+
summary: state.summary,
2491+
domainCount: Array.isArray(state.domainStates) ? state.domainStates.length : 0,
2492+
regionCount: Array.isArray(state.regionalStates) ? state.regionalStates.length : 0,
2493+
situationCount: Array.isArray(state.situationClusters) ? state.situationClusters.length : 0,
2494+
actorCount: Array.isArray(state.actorRegistry) ? state.actorRegistry.length : 0,
2495+
branchCount: Array.isArray(state.branchStates) ? state.branchStates.length : 0,
2496+
}));
2497+
}
2498+
2499+
function buildReportContinuity(current, priorWorldStates = []) {
2500+
const history = summarizeWorldStateHistory(priorWorldStates);
2501+
2502+
const persistentPressures = [];
2503+
const emergingPressures = [];
2504+
const fadingPressures = [];
2505+
const repeatedStrengthening = [];
2506+
const matchedLatestPriorIds = new Set();
2507+
2508+
for (const cluster of current.situationClusters || []) {
2509+
const priorMatches = [];
2510+
for (const state of priorWorldStates.filter(Boolean)) {
2511+
const candidates = Array.isArray(state.situationClusters) ? state.situationClusters : [];
2512+
let match = candidates.find((item) => item.id === cluster.id) || null;
2513+
if (!match) {
2514+
let bestMatch = null;
2515+
let bestScore = 0;
2516+
for (const candidate of candidates) {
2517+
const score = computeSituationSimilarity(cluster, candidate);
2518+
if (score > bestScore) {
2519+
bestScore = score;
2520+
bestMatch = candidate;
2521+
}
2522+
}
2523+
if (bestMatch && bestScore >= 4) match = bestMatch;
2524+
}
2525+
if (!match) continue;
2526+
priorMatches.push({
2527+
id: match.id,
2528+
label: match.label,
2529+
generatedAt: state.generatedAt || 0,
2530+
avgProbability: Number(match.avgProbability || 0),
2531+
forecastCount: Number(match.forecastCount || 0),
2532+
});
2533+
if (state === priorWorldStates[0]) matchedLatestPriorIds.add(match.id);
2534+
}
2535+
2536+
if (priorMatches.length === 0) {
2537+
emergingPressures.push({
2538+
id: cluster.id,
2539+
label: cluster.label,
2540+
forecastCount: cluster.forecastCount,
2541+
avgProbability: cluster.avgProbability,
2542+
});
2543+
continue;
2544+
}
2545+
2546+
persistentPressures.push({
2547+
id: cluster.id,
2548+
label: cluster.label,
2549+
appearances: priorMatches.length + 1,
2550+
forecastCount: cluster.forecastCount,
2551+
avgProbability: cluster.avgProbability,
2552+
});
2553+
2554+
// priorMatches is ordered most-recent-first (mirrors priorWorldStates order from LRANGE)
2555+
const lastMatch = priorMatches[0];
2556+
const earliestMatch = priorMatches[priorMatches.length - 1];
2557+
// "strengthening" means current is >= both the most-recent and oldest prior snapshots,
2558+
// catching recoveries (V-shapes) as well as monotonic increases intentionally
2559+
if (
2560+
cluster.avgProbability >= (lastMatch?.avgProbability || 0) &&
2561+
cluster.avgProbability >= (earliestMatch?.avgProbability || 0) &&
2562+
cluster.forecastCount >= (lastMatch?.forecastCount || 0)
2563+
) {
2564+
repeatedStrengthening.push({
2565+
id: cluster.id,
2566+
label: cluster.label,
2567+
avgProbability: cluster.avgProbability,
2568+
priorAvgProbability: lastMatch?.avgProbability || 0,
2569+
appearances: priorMatches.length + 1,
2570+
});
2571+
}
2572+
}
2573+
2574+
const latestPriorState = priorWorldStates[0] || null;
2575+
for (const cluster of latestPriorState?.situationClusters || []) {
2576+
if (matchedLatestPriorIds.has(cluster.id)) continue;
2577+
fadingPressures.push({
2578+
id: cluster.id,
2579+
label: cluster.label,
2580+
forecastCount: cluster.forecastCount || 0,
2581+
avgProbability: cluster.avgProbability || 0,
2582+
});
2583+
}
2584+
2585+
const summary = history.length
2586+
? `Across the last ${history.length + 1} runs, ${persistentPressures.length} situations persisted, ${emergingPressures.length} emerged, and ${fadingPressures.length} faded from the latest prior snapshot.`
2587+
: 'No prior world-state history is available yet for report continuity.';
2588+
2589+
return {
2590+
history,
2591+
summary,
2592+
persistentPressureCount: persistentPressures.length,
2593+
emergingPressureCount: emergingPressures.length,
2594+
fadingPressureCount: fadingPressures.length,
2595+
repeatedStrengtheningCount: repeatedStrengthening.length,
2596+
persistentPressurePreview: persistentPressures.slice(0, 8),
2597+
emergingPressurePreview: emergingPressures.slice(0, 8),
2598+
fadingPressurePreview: fadingPressures.slice(0, 8),
2599+
repeatedStrengtheningPreview: repeatedStrengthening
2600+
.sort((a, b) => b.appearances - a.appearances || b.avgProbability - a.avgProbability || a.id.localeCompare(b.id))
2601+
.slice(0, 8),
2602+
};
2603+
}
2604+
24832605
function buildWorldStateReport(worldState) {
24842606
const leadDomains = (worldState.domainStates || [])
24852607
.slice(0, 3)
@@ -2541,6 +2663,24 @@ function buildWorldStateReport(worldState) {
25412663
})),
25422664
].slice(0, 6);
25432665

2666+
const continuityWatchlist = [
2667+
...(worldState.reportContinuity?.repeatedStrengtheningPreview || []).map((situation) => ({
2668+
type: 'persistent_strengthening',
2669+
label: situation.label,
2670+
summary: `${situation.label} has strengthened across ${situation.appearances} runs, from ${roundPct(situation.priorAvgProbability)} to ${roundPct(situation.avgProbability)}.`,
2671+
})),
2672+
...(worldState.reportContinuity?.emergingPressurePreview || []).map((situation) => ({
2673+
type: 'emerging_pressure',
2674+
label: situation.label,
2675+
summary: `${situation.label} is a newly emerging situation in the current run.`,
2676+
})),
2677+
...(worldState.reportContinuity?.fadingPressurePreview || []).map((situation) => ({
2678+
type: 'fading_pressure',
2679+
label: situation.label,
2680+
summary: `${situation.label} has faded versus the latest prior world-state snapshot.`,
2681+
})),
2682+
].slice(0, 6);
2683+
25442684
const continuitySummary = `Actors: ${worldState.actorContinuity?.newlyActiveCount || 0} new, ${worldState.actorContinuity?.strengthenedCount || 0} strengthened. Branches: ${worldState.branchContinuity?.newBranchCount || 0} new, ${worldState.branchContinuity?.strengthenedBranchCount || 0} strengthened, ${worldState.branchContinuity?.resolvedBranchCount || 0} resolved. Situations: ${worldState.situationContinuity?.newSituationCount || 0} new, ${worldState.situationContinuity?.strengthenedSituationCount || 0} strengthened, ${worldState.situationContinuity?.resolvedSituationCount || 0} resolved.`;
25452685

25462686
const summary = `${worldState.summary} The leading domains in this run are ${leadDomains.join(', ') || 'none'}, the main continuity changes are captured through ${worldState.actorContinuity?.newlyActiveCount || 0} newly active actors and ${worldState.branchContinuity?.strengthenedBranchCount || 0} strengthened branches, and the situation layer currently carries ${worldState.situationClusters?.length || 0} active clusters.`;
@@ -2557,6 +2697,7 @@ function buildWorldStateReport(worldState) {
25572697
actorWatchlist,
25582698
branchWatchlist,
25592699
situationWatchlist,
2700+
continuityWatchlist,
25602701
keyUncertainties: (worldState.uncertainties || []).slice(0, 6).map(item => item.summary || item),
25612702
};
25622703
}
@@ -2724,6 +2865,9 @@ function buildForecastRunWorldState(data) {
27242865
const situationClusters = buildSituationClusters(predictions);
27252866
const situationContinuity = buildSituationContinuitySummary(situationClusters, priorWorldState);
27262867
const situationSummary = buildSituationSummary(situationClusters, situationContinuity);
2868+
const reportContinuity = buildReportContinuity({
2869+
situationClusters,
2870+
}, data?.priorWorldStates || []);
27272871
const continuity = buildForecastRunContinuity(predictions);
27282872
const evidenceLedger = buildForecastEvidenceLedger(predictions);
27292873
const activeDomains = domainStates.filter((item) => item.forecastCount > 0).map((item) => item.domain);
@@ -2742,6 +2886,7 @@ function buildForecastRunWorldState(data) {
27422886
situationClusters,
27432887
situationContinuity,
27442888
situationSummary,
2889+
reportContinuity,
27452890
continuity,
27462891
evidenceLedger,
27472892
uncertainties: evidenceLedger.counter.slice(0, 10),
@@ -2855,6 +3000,7 @@ function buildForecastTraceArtifacts(data, context = {}, config = {}) {
28553000
generatedAt,
28563001
predictions,
28573002
priorWorldState: data?.priorWorldState || null,
3003+
priorWorldStates: data?.priorWorldStates || [],
28583004
});
28593005
const prefix = buildTraceRunPrefix(
28603006
context.runId || `run_${generatedAt}`,
@@ -2894,6 +3040,7 @@ function buildForecastTraceArtifacts(data, context = {}, config = {}) {
28943040
worldStateSummary: {
28953041
summary: worldState.summary,
28963042
reportSummary: worldState.report?.summary || '',
3043+
reportContinuitySummary: worldState.reportContinuity?.summary || '',
28973044
domainCount: worldState.domainStates.length,
28983045
regionCount: worldState.regionalStates.length,
28993046
situationCount: worldState.situationClusters.length,
@@ -2902,6 +3049,11 @@ function buildForecastTraceArtifacts(data, context = {}, config = {}) {
29023049
strengthenedSituations: worldState.situationContinuity.strengthenedSituationCount,
29033050
weakenedSituations: worldState.situationContinuity.weakenedSituationCount,
29043051
resolvedSituations: worldState.situationContinuity.resolvedSituationCount,
3052+
historyRuns: worldState.reportContinuity?.history?.length || 0,
3053+
persistentPressures: worldState.reportContinuity?.persistentPressureCount || 0,
3054+
emergingPressures: worldState.reportContinuity?.emergingPressureCount || 0,
3055+
fadingPressures: worldState.reportContinuity?.fadingPressureCount || 0,
3056+
repeatedStrengthening: worldState.reportContinuity?.repeatedStrengtheningCount || 0,
29053057
actorCount: worldState.actorRegistry.length,
29063058
persistentActorCount: worldState.actorContinuity.persistentCount,
29073059
newlyActiveActors: worldState.actorContinuity.newlyActiveCount,
@@ -2968,17 +3120,52 @@ async function readPreviousForecastWorldState(storageConfig) {
29683120
}
29693121
}
29703122

3123+
// Returns world states ordered most-recent-first (LPUSH prepends, LRANGE 0 N reads from head).
3124+
// Callers that rely on priorMatches[0] being the most recent must not reorder this array.
3125+
async function readForecastWorldStateHistory(storageConfig, limit = WORLD_STATE_HISTORY_LIMIT) {
3126+
try {
3127+
const { url, token } = getRedisCredentials();
3128+
const resp = await redisCommand(url, token, ['LRANGE', TRACE_RUNS_KEY, 0, Math.max(0, limit - 1)]);
3129+
const rawPointers = Array.isArray(resp?.result) ? resp.result : [];
3130+
const pointers = rawPointers
3131+
.map((value) => {
3132+
try { return JSON.parse(value); } catch { return null; }
3133+
})
3134+
.filter((item) => item?.worldStateKey);
3135+
const seen = new Set();
3136+
const keys = [];
3137+
for (const pointer of pointers) {
3138+
if (seen.has(pointer.worldStateKey)) continue;
3139+
seen.add(pointer.worldStateKey);
3140+
keys.push(pointer.worldStateKey);
3141+
if (keys.length >= limit) break;
3142+
}
3143+
const states = await Promise.all(keys.map((key) => getR2JsonObject(storageConfig, key).catch(() => null)));
3144+
return states.filter(Boolean);
3145+
} catch (err) {
3146+
console.warn(` [Trace] World-state history read failed: ${err.message}`);
3147+
return [];
3148+
}
3149+
}
3150+
29713151
async function writeForecastTraceArtifacts(data, context = {}) {
29723152
const storageConfig = resolveR2StorageConfig();
29733153
if (!storageConfig) return null;
29743154
const predictionCount = Array.isArray(data?.predictions) ? data.predictions.length : 0;
29753155
const traceCap = getTraceCapLog(predictionCount);
29763156
console.log(` Trace cap: raw=${traceCap.raw ?? 'default'} resolved=${traceCap.resolved} total=${traceCap.totalForecasts}`);
29773157

2978-
const priorWorldState = await readPreviousForecastWorldState(storageConfig);
3158+
// Run both reads in parallel; derive priorWorldState from history head to avoid
3159+
// a redundant R2 GET (TRACE_RUNS_KEY[0] and TRACE_LATEST_KEY normally point to the same object).
3160+
const [priorWorldStates, priorWorldStateFallback] = await Promise.all([
3161+
readForecastWorldStateHistory(storageConfig, WORLD_STATE_HISTORY_LIMIT),
3162+
readPreviousForecastWorldState(storageConfig),
3163+
]);
3164+
const priorWorldState = priorWorldStates[0] ?? priorWorldStateFallback;
29793165
const artifacts = buildForecastTraceArtifacts({
29803166
...data,
29813167
priorWorldState,
3168+
priorWorldStates,
29823169
}, context, {
29833170
basePrefix: storageConfig.basePrefix,
29843171
maxForecasts: getTraceMaxForecasts(predictionCount),

0 commit comments

Comments
 (0)