[object Object]

← 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

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 →