← back to Ken
feat(ken): optional weather-news pulse (agent-reach free NWS AFD reader)
7dc1cce309fd02bb65b3f6bebb3c3a652d1a5a6e · 2026-08-13 13:07:12 -0700 · Steve Abrams
Advisory-only, capped (±0.03), contested-markets-only, gated by WEATHER_PULSE=1.
Fail-safe: flag off or any error => no-op (byte-identical). Read-only; never touches
risk.js / order sizing / live-money gates. Verified: flag-off no-op + live $0 OKX AFD fetch.
Files touched
M src/lib/model.jsA src/lib/weather-pulse.js
Diff
commit 7dc1cce309fd02bb65b3f6bebb3c3a652d1a5a6e
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Aug 13 13:07:12 2026 -0700
feat(ken): optional weather-news pulse (agent-reach free NWS AFD reader)
Advisory-only, capped (±0.03), contested-markets-only, gated by WEATHER_PULSE=1.
Fail-safe: flag off or any error => no-op (byte-identical). Read-only; never touches
risk.js / order sizing / live-money gates. Verified: flag-off no-op + live $0 OKX AFD fetch.
---
src/lib/model.js | 13 +++++++-
src/lib/weather-pulse.js | 81 ++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 93 insertions(+), 1 deletion(-)
diff --git a/src/lib/model.js b/src/lib/model.js
index d00373f..527bf3b 100644
--- a/src/lib/model.js
+++ b/src/lib/model.js
@@ -103,12 +103,22 @@ async function generatePrediction({ conditionId, forecastFeatures, eventSpec, mo
// Step 4: Clamp to [0.01, 0.99] to avoid extreme confidence
const pYes = Math.max(0.01, Math.min(0.99, combined));
+ // Step 4b: OPTIONAL weather-news pulse (agent-reach free reader; gated by WEATHER_PULSE=1).
+ // Advisory only — a small capped nudge on CONTESTED markets. Fail-safe: off/error => nudge 0
+ // (byte-identical to the pre-pulse behavior). Never touches sizing/risk/live-money gates.
+ let pulse = { nudge: 0, reason: 'off' };
+ try {
+ const { weatherPulse } = require('./weather-pulse');
+ pulse = await weatherPulse({ pYes, variable, office: eventSpec?.office });
+ } catch { /* fail-safe: no nudge */ }
+ const pYesFinal = Math.max(0.01, Math.min(0.99, pYes + (pulse.nudge || 0)));
+
// Step 5: Confidence based on data quality
const dataPoints = forecastFeatures?.values?.length || 0;
const confidence = Math.min(0.95, dataPoints / 20); // more data = more confidence
return {
- p_yes: Math.round(pYes * 1000) / 1000,
+ p_yes: Math.round(pYesFinal * 1000) / 1000,
confidence: Math.round(confidence * 100) / 100,
method: 'calibrated_ensemble_v1',
components: {
@@ -116,6 +126,7 @@ async function generatePrediction({ conditionId, forecastFeatures, eventSpec, mo
climatological_prior: prior,
bayesian_combined: combined,
data_points: dataPoints,
+ weather_pulse: pulse, // { nudge, reason, score? } — advisory, capped
}
};
}
diff --git a/src/lib/weather-pulse.js b/src/lib/weather-pulse.js
new file mode 100644
index 0000000..3fe1a0f
--- /dev/null
+++ b/src/lib/weather-pulse.js
@@ -0,0 +1,81 @@
+// weather-pulse.js — OPTIONAL free "weather-news pulse" signal for bertha (ken).
+//
+// Reads the NWS Area Forecast Discussion (AFD) text for a market's forecast office via
+// r.jina.ai ($0, no API key) and returns a SMALL, CAPPED confidence nudge for CONTESTED
+// weather markets only. This is the free agent-reach doctrine applied to weather: the
+// underlying reader is `curl https://r.jina.ai/<nws-url>` (present on the box).
+//
+// HARD RAILS:
+// - READ-ONLY. Never sizes or places an order; never touches risk.js or the live-money gates.
+// - ADVISORY. Returns a nudge in [-CAP, +CAP] (default ±0.03) that model.js adds to p_yes,
+// then re-clamps to [0.01, 0.99]. It cannot flip a confident market.
+// - GATED. Disabled unless WEATHER_PULSE=1. Off => { nudge: 0 } (byte-identical behavior).
+// - FAIL-SAFE. Any error / timeout / missing office / non-contested market => { nudge: 0 }.
+// A reader failure must NEVER break a prediction.
+//
+// Env knobs: WEATHER_PULSE=1 (enable), WEATHER_PULSE_CAP (default 0.03),
+// WEATHER_PULSE_CONTEST (default 0.10, the |p-0.5| band that counts as contested).
+
+const CAP = Number(process.env.WEATHER_PULSE_CAP || 0.03);
+const CONTEST = Number(process.env.WEATHER_PULSE_CONTEST || 0.10);
+
+const enabled = () => process.env.WEATHER_PULSE === '1';
+
+// Conservative directional read of AFD text toward "the event variable goes UP".
+// Returns a score in [-1, 1]; 0 when the language is mixed/unclear (the common case).
+// Deliberately modest — the value is the plumbing + transparency; tune the lexicon later.
+function scoreAfd(text, variable) {
+ if (!text || text.length < 200) return 0;
+ const t = text.toLowerCase();
+ const up = ['warmer', 'above normal', 'above average', 'record heat', 'heat wave', 'ridge',
+ 'wetter', 'heavy rain', 'above-normal precipitation', 'increasing', 'trending up',
+ 'higher than', 'exceed'];
+ const down = ['cooler', 'below normal', 'below average', 'cold front', 'trough', 'drier',
+ 'little to no', 'decreasing', 'trending down', 'lower than', 'unlikely to exceed'];
+ // Confidence words scale the signal; hedge words shrink it.
+ const conf = (t.match(/high confidence|confident|well[- ]advertised|strong signal/g) || []).length;
+ const hedge = (t.match(/uncertain|low confidence|spread|could|may|possible|difficult to|question/g) || []).length;
+ let s = 0;
+ for (const w of up) if (t.includes(w)) s += 1;
+ for (const w of down) if (t.includes(w)) s -= 1;
+ if (s === 0) return 0;
+ const dir = Math.sign(s);
+ // magnitude in [0,1]: base on hit count, dampened by hedging, boosted by confidence words
+ let mag = Math.min(1, Math.abs(s) / 6);
+ mag *= (1 + Math.min(0.5, conf * 0.1)) / (1 + Math.min(1, hedge * 0.15));
+ return Math.max(-1, Math.min(1, dir * mag));
+}
+
+// Fetch the AFD text for an NWS office (e.g. "OKX"). Returns '' on any failure.
+async function fetchAfd(office) {
+ const nws = `https://forecast.weather.gov/product.php?site=${office}&issuedby=${office}` +
+ `&product=AFD&format=txt&version=1&glossary=0`;
+ const url = `https://r.jina.ai/${nws}`;
+ const res = await fetch(url, { signal: AbortSignal.timeout(8000) });
+ if (!res.ok) throw new Error('afd_http_' + res.status);
+ return await res.text();
+}
+
+/**
+ * @param {object} a
+ * @param {number} a.pYes current model probability [0,1]
+ * @param {string} a.variable e.g. "tmax"
+ * @param {string} [a.office] NWS office code; if absent => no-op
+ * @returns {Promise<{nudge:number, reason:string, score?:number}>}
+ */
+async function weatherPulse({ pYes, variable, office } = {}) {
+ try {
+ if (!enabled()) return { nudge: 0, reason: 'disabled' };
+ if (typeof pYes !== 'number' || Math.abs(pYes - 0.5) >= CONTEST)
+ return { nudge: 0, reason: 'not_contested' };
+ if (!office) return { nudge: 0, reason: 'no_office' };
+ const text = await fetchAfd(office);
+ const s = scoreAfd(text, variable); // [-1, 1]
+ const nudge = Math.max(-CAP, Math.min(CAP, s * CAP));
+ return { nudge: Math.round(nudge * 1000) / 1000, reason: 'afd', score: Math.round(s * 100) / 100 };
+ } catch (e) {
+ return { nudge: 0, reason: 'error:' + (e && e.message ? e.message : 'x') };
+ }
+}
+
+module.exports = { weatherPulse, scoreAfd };
← f1bde08 chore: v1.0.4 (session close) — auth hardening for public ke
·
back to Ken
·
feat(ken): weather-pulse resolves NWS office from event.loca c87cbf8 →