From 5b2ef866cf69d37067951a64de0507b3e71cc384 Mon Sep 17 00:00:00 2001 From: rrader2890 Date: Mon, 29 Jun 2026 14:16:30 -0400 Subject: [PATCH 1/4] feat(typed): typed-attributes resource (register attrs, ingest typed observations, query) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/resources/typed.ts | 229 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 229 insertions(+) create mode 100644 src/resources/typed.ts diff --git a/src/resources/typed.ts b/src/resources/typed.ts new file mode 100644 index 0000000..8fb6250 --- /dev/null +++ b/src/resources/typed.ts @@ -0,0 +1,229 @@ +import type { HttpClient } from '../core/http-client.js' +import type { RequestOptions } from '../core/types.js' + +/** The declared type of an attribute. */ +export type AttributeDataType = 'numeric' | 'categorical' | 'temporal' | 'boolean' + +/** Acceptance status of an ingested observation. */ +export type ObservationStatus = 'accepted' | 'quarantined' + +/** A registered attribute definition — drives input validation on ingest. */ +export interface AttributeDef { + id: string + platformId?: string + projectId?: string + attributeKey: string + dataType: AttributeDataType + unit?: string + /** Inclusive plausibility bounds; values outside are quarantined. */ + minValid?: number + maxValid?: number + required: boolean + metadataJson?: string +} + +/** Input shape for registering/updating an attribute definition. */ +export interface RegisterAttributeRequest { + attributeKey: string + dataType: AttributeDataType + unit?: string + minValid?: number + maxValid?: number + required?: boolean + metadata?: unknown +} + +/** One typed measurement of an attribute for a subject at a point in time. */ +export interface TypedObservationInput { + id?: string + attributeKey: string + subjectKind: string + subjectExternalId: string + valueNumeric?: number + valueText?: string + valueBool?: boolean + /** ISO-8601 for temporal values. */ + valueTs?: string + /** ISO-8601 observation time. */ + observedAt: string + source?: string + /** Source-trust weight in 0..1 (default 1). */ + trust?: number +} + +export interface TypedObservation extends TypedObservationInput { + id: string + platformId?: string + projectId?: string + qualityScore?: number + status?: ObservationStatus +} + +/** Outcome of a batch ingest. */ +export interface IngestReport { + accepted: number + quarantined: number + duplicates: number + /** observationId -> quarantine reason */ + quarantineReasons: Record +} + +/** Per-(subject, attribute) running statistics. */ +export interface Accumulator { + subjectKind: string + subjectExternalId: string + attributeKey: string + count: number + sum: number + sumSq: number + minVal?: number + maxVal?: number + lastVal?: number + lastObservedAt?: string + cumulative: number + ewma?: number + ewmaVar?: number + /** Derived on read. */ + mean?: number + variance?: number + stddev?: number +} + +export interface QueryObservationsParams { + subjectKind?: string + subjectExternalId?: string + attributeKey?: string + /** ISO-8601 inclusive bounds on observedAt. */ + since?: string + until?: string + minValue?: number + maxValue?: number + status?: ObservationStatus + limit?: number + offset?: number +} + +export interface AccumulatorParams { + subjectKind: string + subjectExternalId: string + attributeKey: string +} + +/** + * Typed attributes — structured/numeric data the engine reasons over + * (credit scores, sensor readings, balances) instead of opaque metadata. + * + * Register an attribute's schema once, then ingest observations: each is + * validated against the definition (accepted or quarantined) and accepted + * numeric values are folded into per-subject accumulators you can read back + * with running mean/variance/min/max/cumulative. Pair with a `memory-value` + * alert rule (see `tf.alerts`) to fire on a threshold/range. + * + * @example + * ```ts + * await tf.typed.registerAttribute({ + * attributeKey: 'credit_score', dataType: 'numeric', minValid: 300, maxValid: 850, + * }) + * const report = await tf.typed.ingest([ + * { attributeKey: 'credit_score', subjectKind: 'contact', subjectExternalId: 'sarah', + * valueNumeric: 650, observedAt: new Date().toISOString() }, + * ]) + * const acc = await tf.typed.accumulator({ + * subjectKind: 'contact', subjectExternalId: 'sarah', attributeKey: 'credit_score', + * }) + * console.log(acc.mean) // 650 + * ``` + */ +export class TypedAttributesResource { + constructor(private readonly http: HttpClient) {} + + /** Register or update an attribute definition (type + plausibility range). */ + async registerAttribute( + body: RegisterAttributeRequest, + options?: RequestOptions, + ): Promise { + return this.http.post('/memory-typed/attributes', body, options) + } + + /** List registered attribute definitions for the project. */ + async listAttributes( + params: { attributeKey?: string; limit?: number; offset?: number } = {}, + options?: RequestOptions, + ): Promise { + return this.http.get( + '/memory-typed/attributes', + { + attributeKey: params.attributeKey, + limit: params.limit, + offset: params.offset, + }, + options, + ) + } + + /** + * Ingest a batch of typed observations synchronously and return the report + * (accepted / quarantined / duplicate counts + quarantine reasons). + */ + async ingest( + observations: TypedObservationInput[], + options?: RequestOptions, + ): Promise { + return this.http.post('/memory-typed/observations', { observations }, options) + } + + /** + * Queue a batch for asynchronous ingest (the scalable path for high volume). + * Returns the count accepted onto the queue; results are folded in by a + * background worker. + */ + async enqueue( + observations: TypedObservationInput[], + options?: RequestOptions, + ): Promise<{ enqueued: number }> { + return this.http.post<{ enqueued: number }>( + '/memory-typed/observations/enqueue', + { observations }, + options, + ) + } + + /** Query raw observations by subject, attribute, time window, and value range. */ + async queryObservations( + params: QueryObservationsParams = {}, + options?: RequestOptions, + ): Promise { + return this.http.get( + '/memory-typed/observations', + { + subjectKind: params.subjectKind, + subjectExternalId: params.subjectExternalId, + attributeKey: params.attributeKey, + since: params.since, + until: params.until, + minValue: params.minValue, + maxValue: params.maxValue, + status: params.status, + limit: params.limit, + offset: params.offset, + }, + options, + ) + } + + /** Read the running statistics for a subject + attribute. */ + async accumulator( + params: AccumulatorParams, + options?: RequestOptions, + ): Promise { + return this.http.get( + '/memory-typed/accumulator', + { + subjectKind: params.subjectKind, + subjectExternalId: params.subjectExternalId, + attributeKey: params.attributeKey, + }, + options, + ) + } +} From bcdecf5a9ced228ae2c9569711f8992a3abe1179 Mon Sep 17 00:00:00 2001 From: rrader2890 Date: Mon, 29 Jun 2026 14:16:31 -0400 Subject: [PATCH 2/4] feat(financial): financial resource (ingest prices/news/holdings, profile, predict, calibration) + end-to-end demo Co-Authored-By: Claude Opus 4.8 (1M context) --- examples/README.md | 42 ++++++ examples/financial-demo.ts | 262 +++++++++++++++++++++++++++++++++++++ src/resources/financial.ts | 217 ++++++++++++++++++++++++++++++ src/types/financial.ts | 234 +++++++++++++++++++++++++++++++++ 4 files changed, 755 insertions(+) create mode 100644 examples/README.md create mode 100644 examples/financial-demo.ts create mode 100644 src/resources/financial.ts create mode 100644 src/types/financial.ts diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..9c3b0c6 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,42 @@ +# Examples + +## `financial-demo.ts` — financial vertical, end to end + +A runnable sample app that pulls **real data from public, no-API-key sources**, +loads it into ThinkFleet memory, and reads the financial vertical back out. + +| Step | What it does | Source | +|------|--------------|--------| +| Pull | Daily price history | Yahoo Finance chart endpoint, JSON (no key) | +| Pull | Recent headlines | Yahoo Finance RSS (no key) | +| Ingest | `tf.financial.ingestPrices` / `ingestNews` / `ingestHolding` | — | +| Read | Indicators + portfolio risk | `tf.financial.getProfile` | +| Read | Calibrated buy/sell/hold calls | `tf.financial.predict` | +| Read | Score due calls + calibration curve | `tf.financial.reconcile` / `getCalibration` | + +### Run it + +```bash +# Data-pull half only — no credentials needed (proves the public feeds work): +npx tsx examples/financial-demo.ts --fetch-only + +# Full end-to-end (needs a project with @thinkfleet/pack-financial enabled): +export THINKFLEET_API_KEY="sk-..." +export THINKFLEET_PROJECT_ID="..." +export THINKFLEET_BASE_URL="https://memory.thinkfleet.ai" # optional +export DEMO_TICKERS="AAPL,MSFT,NVDA" # optional +npm run demo:financial +``` + +### Notes + +- **The engine owns the analysis; this app only maps data in and uses what + comes out.** That's the intended division of responsibility — your system + decides where data originates (here: Yahoo Finance) and what to do with the + results. +- On a **first run**, predictions are made "now" with a 30-day horizon, so + `reconcile` scores 0 and the calibration curve is empty. Run `reconcile` on a + schedule (e.g. daily); the curve fills in as calls mature, and that feedback + is what tunes reported confidence over time. +- **Informational only — not investment advice.** Yahoo Finance data is + provided as-is for demonstration. diff --git a/examples/financial-demo.ts b/examples/financial-demo.ts new file mode 100644 index 0000000..12bf966 --- /dev/null +++ b/examples/financial-demo.ts @@ -0,0 +1,262 @@ +#!/usr/bin/env npx tsx +/** + * @thinkfleet/memory-sdk — financial vertical end-to-end demo + * + * A working sample app that pulls REAL data from public, no-API-key sources, + * loads it into ThinkFleet memory, and reads the financial vertical back out: + * + * 1. Daily price history ← Yahoo Finance chart endpoint (JSON, no key) + * 2. Recent news ← Yahoo Finance RSS (no key) + * 3. ingest → memory (tf.financial.ingestPrices / ingestNews / ingestHolding) + * 4. read → profile (indicators + portfolio risk) + * 5. read → predict (calibrated buy/sell/hold calls) + * 6. read → reconcile + calibration (the self-improving loop) + * + * The engine owns the analysis; this app only shows how an external system + * maps its data in and uses what comes out. Informational only — not advice. + * + * Usage: + * export THINKFLEET_API_KEY="sk-..." + * export THINKFLEET_PROJECT_ID="..." # must have @thinkfleet/pack-financial enabled + * export THINKFLEET_BASE_URL="https://memory.thinkfleet.ai" # optional + * export DEMO_TICKERS="AAPL,MSFT,NVDA" # optional + * npx tsx examples/financial-demo.ts + * + * Tip: you can dry-run the data-pull half with no credentials: + * npx tsx examples/financial-demo.ts --fetch-only + */ + +import { ThinkFleetMemory } from '../src/index.js' +import type { PriceInput, NewsInput } from '../src/index.js' + +// ── Config ────────────────────────────────────────────────────────── + +const FETCH_ONLY = process.argv.includes('--fetch-only') +const API_KEY = process.env.THINKFLEET_API_KEY +const PROJECT_ID = process.env.THINKFLEET_PROJECT_ID +const BASE_URL = process.env.THINKFLEET_BASE_URL ?? 'https://memory.thinkfleet.ai' +const TICKERS = (process.env.DEMO_TICKERS ?? 'AAPL,MSFT,NVDA') + .split(',') + .map((t) => t.trim().toUpperCase()) + .filter(Boolean) +/** The engine's beta benchmark (financial::observation::BENCHMARK_TICKER). */ +const BENCHMARK = 'SPY' +/** ~1 trading year of daily bars is plenty for SMA200 + stable indicators. */ +const HISTORY_BARS = 260 + +if (!FETCH_ONLY && (!API_KEY || !PROJECT_ID)) { + console.error('Missing required environment variables:') + console.error(' THINKFLEET_API_KEY=sk-...') + console.error(' THINKFLEET_PROJECT_ID=... (project must have @thinkfleet/pack-financial enabled)') + console.error(' THINKFLEET_BASE_URL=https://memory.thinkfleet.ai (optional)') + console.error('\nOr run the data-pull half only: npx tsx examples/financial-demo.ts --fetch-only') + process.exit(1) +} + +// ── Public data sources (no API key) ──────────────────────────────── + +/** + * Daily close history from Yahoo Finance's public chart endpoint (JSON, no + * key). Returns ~1 trading year of bars ascending by date. Some sessions can + * have a null close (holidays/gaps) — those are skipped. + */ +async function fetchYahooDaily(ticker: string): Promise { + const url = `https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent( + ticker, + )}?range=1y&interval=1d` + const res = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0 thinkfleet-demo' } }) + if (!res.ok) throw new Error(`Yahoo chart ${ticker}: HTTP ${res.status}`) + const json: any = await res.json() + const result = json?.chart?.result?.[0] + const timestamps: number[] | undefined = result?.timestamp + const quote = result?.indicators?.quote?.[0] + const closes: Array | undefined = quote?.close + const volumes: Array | undefined = quote?.volume + if (!timestamps?.length || !closes?.length) { + throw new Error(`Yahoo chart ${ticker}: no data`) + } + const bars: PriceInput[] = [] + for (let i = 0; i < timestamps.length; i++) { + const close = closes[i] + if (close == null || !Number.isFinite(close) || close <= 0) continue + bars.push({ + ticker, + close, + currency: 'USD', + volume: volumes?.[i] ?? undefined, + asOf: new Date(timestamps[i] * 1000).toISOString(), + }) + } + return bars.slice(-HISTORY_BARS) +} + +/** + * Recent headlines from Yahoo Finance's per-ticker RSS feed. No sentiment is + * supplied, so the engine scores each headline with its built-in lexicon. + */ +async function fetchYahooNews(ticker: string): Promise { + const url = `https://feeds.finance.yahoo.com/rss/2.0/headline?s=${encodeURIComponent( + ticker, + )}®ion=US&lang=en-US` + const res = await fetch(url, { headers: { 'User-Agent': 'thinkfleet-demo' } }) + if (!res.ok) throw new Error(`Yahoo news ${ticker}: HTTP ${res.status}`) + const xml = await res.text() + const items: NewsInput[] = [] + for (const block of xml.split('').slice(1)) { + const headline = decodeXml(matchTag(block, 'title')) + if (!headline) continue + const pub = matchTag(block, 'pubDate') + const publishedAt = pub ? new Date(pub).toISOString() : undefined + items.push({ ticker, headline, source: 'Yahoo Finance', publishedAt }) + } + return items +} + +function matchTag(xml: string, tag: string): string | undefined { + // Handles .. and . + const m = xml.match(new RegExp(`<${tag}>(?:)?`)) + return m?.[1]?.trim() || undefined +} + +function decodeXml(s?: string): string | undefined { + if (!s) return undefined + return s + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'|'/g, "'") +} + +// ── Pretty printing ───────────────────────────────────────────────── + +const fmt = (n: number | null | undefined, d = 2) => + n == null ? '—' : Number(n).toFixed(d) +const pct = (n: number | null | undefined, d = 1) => + n == null ? '—' : `${(Number(n) * 100).toFixed(d)}%` +const hr = (label: string) => console.log(`\n${'─'.repeat(4)} ${label} ${'─'.repeat(Math.max(0, 56 - label.length))}`) + +// ── Main ──────────────────────────────────────────────────────────── + +async function main() { + console.log(`ThinkFleet financial demo · tickers: ${TICKERS.join(', ')} (benchmark ${BENCHMARK})`) + + // 1) Pull public data for every ticker + the benchmark. + hr('1. Pull public data (Yahoo Finance prices + news)') + const symbols = [...TICKERS, BENCHMARK] + const prices: Record = {} + const news: Record = {} + for (const sym of symbols) { + try { + prices[sym] = await fetchYahooDaily(sym) + // News only for the watchlist (not the benchmark). + news[sym] = TICKERS.includes(sym) ? await fetchYahooNews(sym).catch(() => []) : [] + console.log(` ${sym.padEnd(6)} ${prices[sym].length} bars, ${news[sym].length} headlines`) + } catch (err) { + console.warn(` ${sym.padEnd(6)} fetch failed: ${(err as Error).message}`) + prices[sym] = [] + news[sym] = [] + } + } + + if (FETCH_ONLY) { + hr('Fetch-only mode — sample of pulled data') + const sample = TICKERS[0] + console.log(`Latest ${sample} bar:`, prices[sample]?.at(-1)) + console.log(`Latest ${sample} headline:`, news[sample]?.[0]?.headline ?? '(none)') + console.log('\nDone (no ingestion — credentials not required for --fetch-only).') + return + } + + const tf = new ThinkFleetMemory({ + apiKey: API_KEY!, + projectId: PROJECT_ID!, + baseUrl: BASE_URL, + timeout: 60_000, + }) + + // 2) Ingest everything into memory. The engine stores it verbatim; the + // financial plugin reads the metadata shapes on analysis. + hr('2. Ingest into ThinkFleet memory') + for (const sym of symbols) { + if (prices[sym]?.length) { + await tf.financial.ingestPrices(prices[sym]) + console.log(` ingested ${prices[sym].length} ${sym} price bars`) + } + for (const n of news[sym] ?? []) await tf.financial.ingestNews(n) + if (news[sym]?.length) console.log(` ingested ${news[sym].length} ${sym} headlines`) + } + + // 3) Per-ticker profile (indicators) + a calibrated prediction. + for (const ticker of TICKERS) { + const subject = { kind: 'ticker', externalId: ticker } + hr(`3. ${ticker} — indicators + prediction`) + const profile = await tf.financial.getProfile(subject) + const ind = profile.indicators[0] + if (ind) { + console.log( + ` close ${fmt(ind.lastClose)} | RSI14 ${fmt(ind.rsi14)} | SMA50 ${fmt(ind.sma50)} | SMA200 ${fmt(ind.sma200)}`, + ) + console.log( + ` MACD ${fmt(ind.macd, 3)} (hist ${fmt(ind.macdHistogram, 3)}) | vol(ann) ${pct(ind.annualizedVolatility)} | beta ${fmt(ind.beta)} (${ind.betaSource}) | maxDD ${pct(ind.maxDrawdown)}`, + ) + } else { + console.log(' (no indicators — not enough price history ingested)') + } + const { signals, strategyReliability, resolvedSample } = await tf.financial.predict(subject) + for (const s of signals) { + console.log(` → ${s.direction.toUpperCase()} conf ${pct(s.reportedConfidence)} (structural ${pct(s.structuralConfidence)} × reliability ${fmt(strategyReliability)})`) + console.log(` expected ${pct(s.expectedReturn)} over ${s.horizonDays}d · news used: ${s.newsUsed}`) + console.log(` why: ${s.rationale.join(' | ')}`) + } + console.log(` (reliability is based on ${resolvedSample} resolved past call(s))`) + } + + // 4) A demo portfolio → risk rollup. + hr('4. Demo portfolio — risk rollup') + const portfolio = { kind: 'portfolio', externalId: 'demo-portfolio' } + const shares: Record = { AAPL: 100, MSFT: 50, NVDA: 25 } + for (const ticker of TICKERS) { + const last = prices[ticker]?.at(-1)?.close + await tf.financial.ingestHolding(portfolio, { + ticker, + shares: shares[ticker] ?? 10, + costBasis: last ? last * 0.8 : undefined, + assetClass: 'equity', + }) + } + const pf = await tf.financial.getProfile(portfolio) + if (pf.portfolioRisk) { + const r = pf.portfolioRisk + console.log(` total value ${fmt(r.totalValue)} | weighted beta ${fmt(r.weightedBeta)} | weighted vol ${pct(r.weightedAnnualizedVolatility)}`) + console.log(` 1-day 95% VaR ${fmt(r.valueAtRisk95_1d)} (${r.varMethod}) | concentration (HHI) ${fmt(r.concentrationHhi)}`) + for (const a of r.allocations) console.log(` ${a.assetClass}: ${pct(a.weight)} (${fmt(a.value)})`) + } + for (const p of pf.positions) { + console.log(` ${p.ticker}: ${p.shares} @ ${fmt(p.lastClose)} = ${fmt(p.marketValue)} (${pct(p.weight)}) PnL ${fmt(p.unrealizedPnl)}`) + } + if (pf.unpricedHoldings.length) console.log(` unpriced: ${pf.unpricedHoldings.join(', ')}`) + + // 5) The loop: score due predictions, then show calibration. + hr('5. Feedback loop — reconcile + calibration') + const rec = await tf.financial.reconcile() + console.log(` reconcile: scored ${rec.scored} (hits ${rec.hits}, misses ${rec.misses}), still pending ${rec.stillPending}`) + const cal = await tf.financial.getCalibration() + console.log(` calibration over ${cal.totalResolved} resolved call(s), reliability ${fmt(cal.strategyReliability)}:`) + for (const b of cal.buckets) { + const bar = b.hasData ? `realized ${pct(b.realizedHitRate)} (${b.hits}/${b.hits + b.misses})` : '(no data yet)' + console.log(` ${pct(b.lower, 0)}–${pct(b.upper, 0)} conf: ${bar}`) + } + console.log( + '\nNote: on a first run, predictions are made "now" with a 30-day horizon, so reconcile\n' + + 'scores 0 and calibration is empty. Run reconcile on a schedule (e.g. daily) and the\n' + + 'calibration curve fills in as calls mature — that feedback is what tunes confidence.', + ) + + console.log('\nDone. Informational only — not investment advice.') +} + +main().catch((err) => { + console.error('\nDemo failed:', err instanceof Error ? err.message : err) + process.exit(1) +}) diff --git a/src/resources/financial.ts b/src/resources/financial.ts new file mode 100644 index 0000000..c9f0c8b --- /dev/null +++ b/src/resources/financial.ts @@ -0,0 +1,217 @@ +import type { HttpClient } from '../core/http-client.js' +import type { RequestOptions } from '../core/types.js' +import { MemoryItemType, MemoryScope, type MemoryItem } from '../types/memory.js' +import type { + CalibrationOptions, + FinancialCalibrationReport, + FinancialProfile, + FundamentalInput, + HoldingInput, + NewsInput, + PredictFinancialResult, + PredictOptions, + PriceInput, + ReconcileFinancialResult, + Subject, +} from '../types/financial.js' + +/** + * Financial — technical indicators, portfolio risk, and a self-calibrating + * directional prediction loop for the memory engine's financial vertical. + * + * Financial data is just memory data: you ingest price bars, fundamentals, + * holdings, and news as memory items, and the engine derives indicators + * (SMA/EMA, RSI, MACD, Bollinger, volatility, drawdown, Sharpe, beta), + * portfolio risk (VaR, weighted beta, HHI concentration, allocation), and + * buy/sell/hold calls whose REPORTED confidence is the strategy's structural + * agreement times its *realized* hit-rate. As calls come due they are scored + * against actual prices (`reconcile`), and that feedback recalibrates future + * confidence — so the engine gets more honest over time, not just louder. + * + * You decide where the data originates — a market-data vendor, a brokerage + * feed, a news scraper. The SDK only gives you the typed way in and out. + * + * Requires the `@thinkfleet/pack-financial` pack enabled on the project; the + * read methods return FAILED_PRECONDITION otherwise. Ingestion works + * regardless (it's plain memory). + * + * Everything here is informational only — NOT investment advice. + * + * Authorization: the engine isolates by tenant (your API key → platform + + * project). A `subject` (e.g. a portfolio) is caller-asserted — the engine + * does NOT verify the end-user owns the subject you pass. In a multi-user + * app YOU are responsible for only ever passing a subject the current user + * is entitled to. Market data (prices/fundamentals/news) is pooled across + * the project; holdings are private to their subject. + * + * @example + * ```ts + * // Backfill price history (market data — no subject). + * await tf.financial.ingestPrices(history.map((b) => ({ ticker: 'AAPL', close: b.close, asOf: b.date }))) + * await tf.financial.ingestNews({ ticker: 'AAPL', headline: 'Apple beats earnings', sentiment: 0.7 }) + * + * // Record a portfolio position (subject-private). + * const portfolio = { kind: 'portfolio', externalId: 'acct-123' } + * await tf.financial.ingestHolding(portfolio, { ticker: 'AAPL', shares: 100, costBasis: 150 }) + * + * // Read indicators + risk, and generate calibrated calls. + * const profile = await tf.financial.getProfile(portfolio) + * const { signals, strategyReliability } = await tf.financial.predict({ kind: 'ticker', externalId: 'AAPL' }) + * + * // Later — score due calls and inspect calibration. + * await tf.financial.reconcile() + * const cal = await tf.financial.getCalibration() + * ``` + */ +export class FinancialResource { + constructor(private readonly http: HttpClient) {} + + // ── Input — ingest market data + positions (stored as memory items) ── + + /** Ingest a single price bar. Market data — not subject-attributed. */ + async ingestPrice(price: PriceInput, options?: RequestOptions): Promise { + return this.http.post( + '/admin/memory', + { + content: `${price.ticker} close ${price.close}${price.asOf ? ` @ ${price.asOf}` : ''}`, + type: MemoryItemType.FACT, + scope: MemoryScope.PROJECT, + category: 'financial', + source: 'sdk:financial', + metadata: { price }, + }, + options, + ) + } + + /** + * Ingest many price bars (e.g. a backfill). Issued concurrently; resolves + * once all are stored. For very large histories, batch in chunks yourself. + */ + async ingestPrices(prices: PriceInput[], options?: RequestOptions): Promise { + return Promise.all(prices.map((p) => this.ingestPrice(p, options))) + } + + /** Ingest/refresh a ticker's fundamentals. Latest values win. */ + async ingestFundamentals( + fundamental: FundamentalInput, + options?: RequestOptions, + ): Promise { + return this.http.post( + '/admin/memory', + { + content: `Fundamentals ${fundamental.ticker}`, + type: MemoryItemType.FACT, + scope: MemoryScope.PROJECT, + category: 'financial', + source: 'sdk:financial', + metadata: { fundamental }, + }, + options, + ) + } + + /** + * Record a portfolio position. Subject-private — attributed to the owner + * (use a `{ kind: 'portfolio', externalId }` subject). Restated, not + * summed: re-recording a ticker replaces the prior position. + */ + async ingestHolding( + subject: Subject, + holding: HoldingInput, + options?: RequestOptions, + ): Promise { + return this.http.post( + '/admin/memory', + { + content: `Holding ${holding.shares} ${holding.ticker}`, + type: MemoryItemType.FACT, + scope: MemoryScope.PROJECT, + category: 'financial', + source: 'sdk:financial', + metadata: { subject, holding }, + }, + options, + ) + } + + /** Ingest a news event. Market data — tag one or many tickers. */ + async ingestNews(news: NewsInput, options?: RequestOptions): Promise { + const label = news.ticker ?? news.tickers?.join(',') ?? 'news' + return this.http.post( + '/admin/memory', + { + content: `News [${label}]: ${news.headline}`, + type: MemoryItemType.FACT, + scope: MemoryScope.PROJECT, + category: 'financial', + source: 'sdk:financial', + metadata: { newsEvent: news }, + }, + options, + ) + } + + // ── Read — indicators, risk, calibrated predictions ── + + /** + * Technical indicators + (for a portfolio subject) a risk rollup, derived + * from ingested market data and holdings. Read-only and forecast-free. + * + * `subject.kind === 'ticker'` → single-name analysis (externalId is the + * ticker). Any other kind → portfolio mode over the subject's holdings. + */ + async getProfile(subject: Subject, options?: RequestOptions): Promise { + return this.http.post('/lattice/financial/profile', { subject }, options) + } + + /** + * Generate directional buy/sell/hold calls. Reported confidence = + * structural agreement × the strategy's realized reliability. By default + * each call is persisted so it can be scored at horizon by `reconcile`. + */ + async predict( + subject: Subject, + opts?: PredictOptions, + options?: RequestOptions, + ): Promise { + return this.http.post( + '/lattice/financial/predict', + { + subject, + ...(opts?.horizonDays != null ? { horizonDays: opts.horizonDays } : {}), + ...(opts?.persist != null ? { persist: opts.persist } : {}), + }, + options, + ) + } + + /** + * Run the feedback loop: score every persisted prediction whose horizon has + * elapsed against the realized close, and mark it resolved. This is what + * makes the engine learn — reliability and calibration are recomputed from + * these outcomes. Idempotent; safe to run on a schedule. + */ + async reconcile(options?: RequestOptions): Promise { + return this.http.post('/lattice/financial/reconcile', {}, options) + } + + /** + * The honesty proof: resolved predictions bucketed by the confidence we + * reported, with the realized hit-rate per band — so "calls we rated ~70%" + * can be checked against whether ~70% actually landed. + */ + async getCalibration( + opts?: CalibrationOptions, + options?: RequestOptions, + ): Promise { + return this.http.post( + '/lattice/financial/calibration', + { + ...(opts?.bucketCount != null ? { bucketCount: opts.bucketCount } : {}), + ...(opts?.strategy != null ? { strategy: opts.strategy } : {}), + }, + options, + ) + } +} diff --git a/src/types/financial.ts b/src/types/financial.ts new file mode 100644 index 0000000..8af66b5 --- /dev/null +++ b/src/types/financial.ts @@ -0,0 +1,234 @@ +// Financial prediction types — technical indicators, portfolio risk, and a +// self-calibrating directional signal loop, served by the memory engine's +// financial vertical (gated behind the @thinkfleet/pack-financial pack). +// +// Financial data IS memory data: you record price bars, fundamentals, +// portfolio holdings, and news events as memory items (see +// FinancialResource.ingest*), and the engine derives indicators, risk, and +// directional calls from them. You decide where the data comes from — a +// market-data vendor, a brokerage feed, an RSS scraper — the SDK just gives +// you the typed way in and out. +// +// Market data (prices / fundamentals / news) is keyed by ticker and shared +// across subjects. Holdings are subject-private (attributed to a portfolio). + +import type { Subject } from './lattice.js' + +export type { Subject } + +// ── Ingestion inputs ── + +/** One price bar (daily close). Emit one per trading day to build history. */ +export interface PriceInput { + ticker: string + close: number + currency?: string + volume?: number + /** ISO timestamp of the bar; defaults to ingestion time. */ + asOf?: string +} + +/** Latest-wins fundamentals for a ticker. */ +export interface FundamentalInput { + ticker: string + peRatio?: number + marketCap?: number + dividendYield?: number + eps?: number + debtToEquity?: number + /** Vendor-reported beta. The engine also computes beta from price history. */ + beta?: number + asOf?: string +} + +/** A portfolio position. Restated, not summed — the latest record wins. */ +export interface HoldingInput { + ticker: string + shares: number + costBasis?: number + /** "equity" | "bond" | "cash" | "crypto" | ... Defaults to "equity". */ + assetClass?: string +} + +/** A news event. Tag one ticker or many; supply a sentiment in [-1, 1] if you + * have a vendor/LLM score, otherwise the engine falls back to a lexicon. */ +export interface NewsInput { + ticker?: string + tickers?: string[] + headline: string + /** [-1, 1]; omit to let the engine score the headline. */ + sentiment?: number + source?: string + publishedAt?: string +} + +// ── Read shapes — profile ── + +export interface TechnicalIndicators { + ticker: string + lastClose: number + asOf: string + sma20?: number | null + sma50?: number | null + sma200?: number | null + ema12?: number | null + ema26?: number | null + rsi14?: number | null + macd?: number | null + macdSignal?: number | null + macdHistogram?: number | null + bollingerUpper?: number | null + bollingerMid?: number | null + bollingerLower?: number | null + bollingerPctB?: number | null + annualizedVolatility?: number | null + trailingReturn?: number | null + /** Negative fraction, e.g. -0.25 for a 25% peak-to-trough decline. */ + maxDrawdown?: number | null + sharpe?: number | null + beta?: number | null + /** "none" | "computed" (from price history) | "reported" (vendor). */ + betaSource: string + sampleSize: number + sourceMemoryIds: string[] +} + +export interface FundamentalSnapshot { + ticker: string + peRatio?: number | null + marketCap?: number | null + dividendYield?: number | null + eps?: number | null + debtToEquity?: number | null + beta?: number | null + asOf: string + sourceMemoryId: string +} + +export interface PortfolioPosition { + ticker: string + shares: number + costBasis?: number | null + lastClose: number + marketValue: number + /** Fraction of total portfolio value. */ + weight: number + unrealizedPnl?: number | null + assetClass: string +} + +export interface AssetAllocation { + assetClass: string + value: number + weight: number +} + +export interface PortfolioRisk { + totalValue: number + weightedBeta?: number | null + weightedAnnualizedVolatility?: number | null + /** Parametric 1-day 95% VaR in currency units; see varMethod (ignores + * cross-asset correlation). */ + valueAtRisk95_1d?: number | null + /** Herfindahl index of position weights, [0, 1]. 1 = single name. */ + concentrationHhi: number + allocations: AssetAllocation[] + varMethod: string +} + +export interface FinancialProfile { + subject: Subject + indicators: TechnicalIndicators[] + fundamentals: FundamentalSnapshot[] + /** Empty in ticker mode (subject.kind === "ticker"). */ + positions: PortfolioPosition[] + /** Absent in ticker mode or when the portfolio has no priced value. */ + portfolioRisk?: PortfolioRisk | null + /** Held tickers with no market data in the corpus (couldn't be priced). */ + unpricedHoldings: string[] + /** Always populated — informational only, not investment advice. */ + disclaimer: string + generatedAt: string +} + +// ── Read shapes — prediction loop ── + +export type Direction = 'buy' | 'sell' | 'hold' + +export interface FinancialSignal { + ticker: string + strategy: string + direction: Direction + /** Blended sub-signal score in [-1, 1], bullish positive. */ + score: number + /** Raw model agreement before calibration. */ + structuralConfidence: number + /** The number to trust: structural × the strategy's realized reliability. */ + reportedConfidence: number + expectedReturn: number + horizonDays: number + basisClose: number + /** ISO timestamp when the call becomes scoreable. */ + dueAt: string + rationale: string[] + newsUsed: boolean + /** Set when the call was persisted for later scoring. */ + predictionId?: string | null + sourceMemoryIds: string[] +} + +export interface PredictFinancialResult { + signals: FinancialSignal[] + strategy: string + /** Reliability multiplier applied this run, and how many resolved calls it + * was computed from (0 = untested → multiplier 1.0). */ + strategyReliability: number + resolvedSample: number + disclaimer: string + generatedAt: string +} + +export interface ReconcileFinancialResult { + /** Newly resolved this pass. */ + scored: number + hits: number + misses: number + /** Due-or-not, not yet scoreable. */ + stillPending: number + generatedAt: string +} + +export interface FinancialCalibrationBucket { + lower: number + upper: number + predictions: number + hits: number + misses: number + realizedHitRate: number + hasData: boolean +} + +export interface FinancialCalibrationReport { + buckets: FinancialCalibrationBucket[] + /** "all" when unfiltered. */ + strategy: string + strategyReliability: number + totalResolved: number + generatedAt: string +} + +// ── Method option bags ── + +export interface PredictOptions { + /** Horizon in days; default 30, clamped [1, 365]. */ + horizonDays?: number + /** Persist each call for later scoring; default true. */ + persist?: boolean +} + +export interface CalibrationOptions { + /** Number of confidence bands; default 5, clamped [1, 20]. */ + bucketCount?: number + /** Filter to one strategy; omit for all. */ + strategy?: string +} From 1de006aed76fad585443488f613bbcf5bc70abb0 Mon Sep 17 00:00:00 2001 From: rrader2890 Date: Mon, 29 Jun 2026 14:16:31 -0400 Subject: [PATCH 3/4] =?UTF-8?q?feat(predict):=20v2=20general-prediction=20?= =?UTF-8?q?surface=20=E2=80=94=20declared=20targets,=20abstention,=20prove?= =?UTF-8?q?nance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds PredictionTarget (event_occurrence/numeric/event_time/anomaly) on PredictRequest, TargetPrediction + first-class abstained/abstentionReason on PredictResult, and an ergonomic tf.lattice.predictTarget() helper. Mirrors the engine v2 contract the product API already serves. Includes a predict-anything example and README section. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 38 ++++++++++ examples/predict-anything.ts | 140 +++++++++++++++++++++++++++++++++++ src/resources/lattice.ts | 90 +++++++++++++++++++++- src/types/lattice.ts | 102 +++++++++++++++++++++++++ 4 files changed, 366 insertions(+), 4 deletions(-) create mode 100644 examples/predict-anything.ts diff --git a/README.md b/README.md index 8a75dac..8f790e8 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,44 @@ const tf = new ThinkFleetMemory({ --- +## Predict anything (v2) + +Don't pick a model. Declare *what* to predict — a churn event, a next-order +amount, a next-visit time, an anomaly — and the engine predicts it from the +subject's history, **calibrated, with provenance, and abstaining when there +isn't enough signal**. + +```ts +const p = await tf.lattice.predictTarget( + { kind: 'customer', externalId: 'acct-42' }, + { kind: 'event_occurrence', eventType: 'subscription_cancelled' }, + { horizonDays: 90 }, +) + +if (p.abstained) { + // The whole trust story: "unknown" is a first-class answer. Never treat an + // abstention as low risk. + console.log('not enough signal —', p.abstentionReason) +} else { + console.log(`churn risk ${(p.probability * 100).toFixed(0)}% ` + + `[${(p.probabilityLower * 100).toFixed(0)}–${(p.probabilityUpper * 100).toFixed(0)}%]`) + console.log('why:', p.explanation, '| evidence:', p.evidenceMemoryIds) +} +``` + +`target.kind` is one of `event_occurrence` | `numeric` | `event_time` | +`anomaly`, and the kind selects the result fields (`probability*` / `value*` / +`expectedAt*` / `anomalyScore`). A target is just a question — adding a row to +your registry adds a prediction, with no SDK or engine change. That's how a +whole vertical (Shopify churn, health risk, fraud) is one registry over one +engine. + +See [`examples/predict-anything.ts`](examples/predict-anything.ts) for all four +kinds end-to-end. The lower-level `tf.lattice.predict({ subject, target })` +returns the full `PredictResult` (`targetPrediction` + top-level `abstained`). + +--- + ## Memory scopes ```ts diff --git a/examples/predict-anything.ts b/examples/predict-anything.ts new file mode 100644 index 0000000..c534b3b --- /dev/null +++ b/examples/predict-anything.ts @@ -0,0 +1,140 @@ +#!/usr/bin/env npx tsx +/** + * @thinkfleet/memory-sdk — v2 "predict anything" + abstention demo + * + * The whole moat in one file: declare ANY target and the engine predicts it + * from a subject's observation history — calibrated, with provenance, and + * *abstaining* when there isn't enough signal. No model picking, no per-target + * RPC, no canned detector menu. + * + * It shows all four target kinds against the SAME subject: + * 1. event_occurrence → will they churn / reorder within the horizon? + * 2. numeric → what's their next order total? + * 3. event_time → when is their next visit? + * 4. anomaly → is their latest reading an outlier? + * + * The point to notice: a fresh subject with thin history comes back + * `abstained` — "I don't know yet" — instead of a confident guess. That's the + * trust layer competitors don't ship. + * + * Usage: + * export THINKFLEET_API_KEY="sk-..." + * export THINKFLEET_PROJECT_ID="..." + * export THINKFLEET_BASE_URL="https://memory.thinkfleet.ai" # optional + * export DEMO_SUBJECT="customer:acct-42" # optional, kind:externalId + * npx tsx examples/predict-anything.ts + */ + +import { ThinkFleetMemory } from '../src/index.js' +import type { PredictionTarget, TargetPrediction, Subject } from '../src/index.js' + +// ── Config ────────────────────────────────────────────────────────── + +const API_KEY = process.env.THINKFLEET_API_KEY +const PROJECT_ID = process.env.THINKFLEET_PROJECT_ID +const BASE_URL = process.env.THINKFLEET_BASE_URL ?? 'https://memory.thinkfleet.ai' + +const [subjectKind, subjectId] = (process.env.DEMO_SUBJECT ?? 'customer:acct-42').split(':') +const SUBJECT: Subject = { kind: subjectKind, externalId: subjectId } + +if (!API_KEY || !PROJECT_ID) { + console.error('Set THINKFLEET_API_KEY and THINKFLEET_PROJECT_ID first.') + process.exit(1) +} + +// ── The target registry — declare WHAT to predict, not HOW ────────── +// +// This is the v2 contract: each entry is a question, not a model. Add a row +// here and you've added a prediction — no SDK or engine change required. This +// is exactly how a vertical (Shopify churn, health risk, fraud) is just a +// different registry over the same engine. + +const TARGETS: Array<{ label: string; horizonDays: number; target: PredictionTarget }> = [ + { + label: 'Reorder within 90 days?', + horizonDays: 90, + target: { kind: 'event_occurrence', eventType: 'order_placed' }, + }, + { + label: 'Next order total', + horizonDays: 30, + target: { kind: 'numeric', attributeKey: 'order_total' }, + }, + { + label: 'When is the next visit?', + horizonDays: 60, + target: { kind: 'event_time', eventType: 'visit' }, + }, + { + label: 'Is the latest resting HR an outlier?', + horizonDays: 30, + target: { kind: 'anomaly', attributeKey: 'resting_hr' }, + }, +] + +// ── Render one prediction the way a caller SHOULD — abstention first ─ + +function render(label: string, p: TargetPrediction): void { + console.log(`\n• ${label}`) + + // The non-negotiable rule: an abstention is "unknown", never "no/low risk". + if (p.abstained) { + console.log(` ↳ ABSTAINED — ${p.abstentionReason || 'insufficient signal'}`) + console.log(` (treat as unknown; do not act as if the answer were "no")`) + return + } + + switch (p.targetKind) { + case 'event_occurrence': + console.log( + ` ↳ ${(p.probability * 100).toFixed(0)}% ` + + `[${(p.probabilityLower * 100).toFixed(0)}–${(p.probabilityUpper * 100).toFixed(0)}%]`, + ) + break + case 'numeric': + console.log(` ↳ ${p.value.toFixed(2)} [${p.valueLower.toFixed(2)}–${p.valueUpper.toFixed(2)}]`) + break + case 'event_time': + console.log(` ↳ ~${p.daysUntil.toFixed(0)} days (${p.expectedAt})`) + console.log(` window: ${p.expectedAtLower} → ${p.expectedAtUpper}`) + break + case 'anomaly': + console.log( + ` ↳ ${p.isAnomaly ? 'ANOMALY' : 'normal'} — z=${p.anomalyScore.toFixed(2)}, ` + + `latest ${p.value.toFixed(2)} vs [${p.valueLower.toFixed(2)}–${p.valueUpper.toFixed(2)}]`, + ) + break + default: + console.log(` ↳ ${JSON.stringify(p)}`) + } + + if (p.explanation) console.log(` why: ${p.explanation}`) + if (p.evidenceMemoryIds.length) { + console.log(` evidence: ${p.evidenceMemoryIds.slice(0, 3).join(', ')}` + + (p.evidenceMemoryIds.length > 3 ? ` (+${p.evidenceMemoryIds.length - 3} more)` : '')) + } +} + +// ── Run ───────────────────────────────────────────────────────────── + +async function main(): Promise { + const tf = new ThinkFleetMemory({ apiKey: API_KEY!, projectId: PROJECT_ID!, baseUrl: BASE_URL }) + + console.log(`Predicting for ${SUBJECT.kind}:${SUBJECT.externalId} — ${TARGETS.length} declared targets`) + + for (const { label, horizonDays, target } of TARGETS) { + try { + const p = await tf.lattice.predictTarget(SUBJECT, target, { horizonDays }) + render(label, p) + } catch (err) { + console.log(`\n• ${label}\n ↳ error: ${(err as Error).message}`) + } + } + + console.log('\nDone. Note which targets abstained — that honesty is the product.') +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) diff --git a/src/resources/lattice.ts b/src/resources/lattice.ts index 3a3903f..db329ca 100644 --- a/src/resources/lattice.ts +++ b/src/resources/lattice.ts @@ -13,6 +13,8 @@ import type { MonitorTickResult, PredictRequest, PredictResult, + PredictionTarget, + TargetPrediction, EstimateRequest, EstimateResult, CalibrationReport, @@ -163,13 +165,22 @@ export class LatticeResource { } /** - * Project future events for a subject based on their active - * behavior patterns. Each prediction names the pattern that drove - * it and the raw memories that produced the pattern (provenance) - * so callers can explain or audit any single prediction. + * Predict for a subject. Two modes: + * + * 1. **Pattern projection (default).** Omit `target` to project the + * subject's active behavior patterns forward — each prediction names the + * pattern that drove it and the raw memories behind it (provenance). + * 2. **Declared target (v2 general prediction).** Pass a `target` to predict + * *anything* — a churn event, a next-order amount, a next-visit time, an + * anomaly — straight from the subject's observation history, no pre-mined + * pattern required. The estimate arrives in `result.targetPrediction` + * with a calibrated interval and first-class abstention. + * + * For the declared-target case, prefer the typed {@link predictTarget} helper. * * @example * ```ts + * // 1. pattern projection * const result = await tf.lattice.predict({ * subject: { kind: 'contact', externalId: 'sarah-pizza' }, * horizonDays: 30, @@ -177,6 +188,14 @@ export class LatticeResource { * for (const p of result.predictions) { * console.log(`${p.expectedAt}: ${p.description} (conf ${p.confidence.toFixed(2)})`) * } + * + * // 2. declared target + * const churn = await tf.lattice.predict({ + * subject: { kind: 'contact', externalId: 'sarah-pizza' }, + * horizonDays: 90, + * target: { kind: 'event_occurrence', eventType: 'order_placed' }, + * }) + * console.log(churn.targetPrediction?.probability, churn.abstained) * ``` */ async predict( @@ -186,6 +205,69 @@ export class LatticeResource { return this.http.post('/lattice/predict', body, options) } + /** + * v2 general prediction, typed and ergonomic: declare *what* to predict and + * get back the single calibrated {@link TargetPrediction} (or an abstention). + * Thin wrapper over {@link predict} with a `target`. + * + * Always check `.abstained` before reading a value — an abstention means + * "not enough signal", which you must treat as *unknown*, never as low risk. + * + * @example + * ```ts + * const p = await tf.lattice.predictTarget( + * { kind: 'customer', externalId: 'acct-42' }, + * { kind: 'event_occurrence', eventType: 'subscription_cancelled' }, + * { horizonDays: 90 }, + * ) + * if (p.abstained) { + * console.log('unknown —', p.abstentionReason) + * } else { + * console.log(`churn risk ${(p.probability * 100).toFixed(0)}% ` + * + `[${(p.probabilityLower * 100).toFixed(0)}–${(p.probabilityUpper * 100).toFixed(0)}%]`) + * console.log('because:', p.explanation, '| evidence:', p.evidenceMemoryIds) + * } + * ``` + * + * @returns the `targetPrediction`. If the engine returns none (it always + * does in target mode), a synthetic abstention is returned so callers never + * have to null-check. + */ + async predictTarget( + subject: Subject, + target: PredictionTarget, + params?: { horizonDays?: number }, + options?: RequestOptions, + ): Promise { + const result = await this.predict( + { subject, target, horizonDays: params?.horizonDays }, + options, + ) + return ( + result.targetPrediction ?? { + targetKind: target.kind, + eventType: target.eventType ?? target.attributeKey ?? '', + probability: 0, + probabilityLower: 0, + probabilityUpper: 0, + value: 0, + valueLower: 0, + valueUpper: 0, + expectedAt: '', + expectedAtLower: '', + expectedAtUpper: '', + daysUntil: 0, + anomalyScore: 0, + isAnomaly: false, + abstained: true, + abstentionReason: + result.abstentionReason || 'insufficient_signal: engine returned no target estimate', + explanation: '', + evidenceMemoryIds: [], + } + ) + } + /** * Behavioral profile snapshot for a subject — RFM segment + top * entity + cadence summary + risk indicators. Non-temporal diff --git a/src/types/lattice.ts b/src/types/lattice.ts index 2c75775..5480698 100644 --- a/src/types/lattice.ts +++ b/src/types/lattice.ts @@ -236,6 +236,52 @@ export interface PredictRequest { emitEvents?: boolean /** Imminence window for event emission, in hours. Default 48, clamped [1, 720]. */ imminentWithinHours?: number + /** + * v2 general prediction. When set, the engine predicts THIS declared target + * from the subject's observation history instead of projecting mined behavior + * patterns — "predict anything", not the canned menu. The result arrives in + * `PredictResult.targetPrediction` (with first-class abstention). When unset, + * `predict()` behaves exactly as before (pattern projection). + */ + target?: PredictionTarget +} + +/** Target kinds the v2 engine can predict. The kind drives model selection. */ +export type TargetKind = 'event_occurrence' | 'numeric' | 'event_time' | 'anomaly' + +/** + * A declaratively-specified prediction target (v2). You declare *what* to + * predict; the engine selects the model family from `kind`. Callers never pick + * a model. + * + * @example + * ```ts + * // Will this customer reorder in the next 30 days? + * { kind: 'event_occurrence', eventType: 'order_placed' } + * // What will their next order total be? + * { kind: 'numeric', attributeKey: 'order_total' } + * // When is their next visit expected? + * { kind: 'event_time', eventType: 'visit' } + * // Is their latest reading an outlier? + * { kind: 'anomaly', attributeKey: 'resting_hr' } + * ``` + */ +export interface PredictionTarget { + /** Target type — drives model selection. */ + kind: TargetKind + /** + * For event_occurrence / event_time: the activity event to predict. Matched + * against an observation's eventType, then category, then content substring. + * Empty = "any activity". + */ + eventType?: string + /** + * For numeric / anomaly: the typed-observation attribute to predict (e.g. + * "order_total"). Required for those kinds; ignored otherwise. + */ + attributeKey?: string + /** How many days of history to learn from. Default 365, clamped [1, 3650]. */ + lookbackDays?: number } /** One projected event derived from one active behavior pattern. */ @@ -248,12 +294,54 @@ export interface PredictedEvent { expectedAt: string /** 0..1 confidence inherited from the pattern's dominance score. */ confidence: number + /** Calibrated 95% interval around `confidence` (Wilson score). */ + confidenceLower?: number + confidenceUpper?: number /** Tolerance window in minutes. */ windowMinutes: number /** Provenance — raw memories that produced the source pattern. */ sourceMemoryIds: string[] } +/** + * The single calibrated estimate for a declared `target`. Exactly one field + * group is meaningful per `targetKind`: + * - event_occurrence → `probability` (+ lower/upper) + * - numeric → `value` (+ lower/upper) + * - event_time → `expectedAt` (+ lower/upper, `daysUntil`) + * - anomaly → `anomalyScore` / `isAnomaly` (value = latest reading) + * + * Always check `abstained` first: when true, the engine declined to guess — + * treat it as "unknown", never as "no/low risk". + */ +export interface TargetPrediction { + targetKind: TargetKind | string + eventType: string + /** event_occurrence: P(event within horizon), calibrated. */ + probability: number + probabilityLower: number + probabilityUpper: number + /** numeric: predicted next value + 95% interval. */ + value: number + valueLower: number + valueUpper: number + /** event_time: when the next occurrence is expected (ISO-8601) + interval. */ + expectedAt: string + expectedAtLower: string + expectedAtUpper: string + daysUntil: number + /** anomaly: |z| from baseline, and whether it crosses the threshold. */ + anomalyScore: number + isAnomaly: boolean + /** First-class abstention — true when there isn't enough signal to estimate. */ + abstained: boolean + abstentionReason: string + /** Human-readable derivation (counts, rate, horizon) for explainability. */ + explanation: string + /** Provenance: ids of the observations the estimate was derived from. */ + evidenceMemoryIds: string[] +} + export interface PredictResult { subject: Subject predictions: PredictedEvent[] @@ -264,6 +352,20 @@ export interface PredictResult { /** ISO timestamp the prediction was generated. */ generatedAt: string durationMs: number + /** + * First-class abstention: true when the engine declines to predict because + * there isn't enough signal. Callers MUST treat an abstention as "unknown", + * never as "no/low risk" — this is what keeps the engine safe for regulated + * use. + */ + abstained?: boolean + /** Machine-readable reason when `abstained` is true (empty otherwise). */ + abstentionReason?: string + /** + * Set instead of `predictions` when the request carried a declarative + * `target`: the single calibrated estimate for that target. + */ + targetPrediction?: TargetPrediction | null } // ── Profile ────────────────────────────────────────────────────── From 97afd3a0dd1202d10460820a48a84baab6275ba1 Mon Sep 17 00:00:00 2001 From: rrader2890 Date: Mon, 29 Jun 2026 14:16:31 -0400 Subject: [PATCH 4/4] chore(client): wire financial + typed resources; export v2 predict + financial/typed types Co-Authored-By: Claude Opus 4.8 (1M context) --- package.json | 3 ++- src/client.ts | 6 ++++++ src/index.ts | 40 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 26e82f5..8a68ce2 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "check:proto": "node scripts/check-proto-drift.mjs", "test:integration": "tsx test-app.ts", "seed": "tsx scripts/seed-memory.ts", - "seed:lattice": "tsx scripts/seed-lattice.ts" + "seed:lattice": "tsx scripts/seed-lattice.ts", + "demo:financial": "tsx examples/financial-demo.ts" }, "devDependencies": { "@types/node": "^25.9.1", diff --git a/src/client.ts b/src/client.ts index 1fd3cc8..7aab539 100644 --- a/src/client.ts +++ b/src/client.ts @@ -4,9 +4,11 @@ import { AlertsResource } from './resources/alerts.js' import { ComplianceResource } from './resources/compliance.js' import { ContextResource } from './resources/context.js' import { EventsResource } from './resources/events.js' +import { FinancialResource } from './resources/financial.js' import { HealthResource } from './resources/health.js' import { LatticeResource } from './resources/lattice.js' import { MemoryResource } from './resources/memory.js' +import { TypedAttributesResource } from './resources/typed.js' export interface ThinkFleetMemoryOptions { /** API key (`sk-...`) from Platform Admin → API Keys. */ @@ -56,6 +58,8 @@ export class ThinkFleetMemory { readonly alerts: AlertsResource readonly compliance: ComplianceResource readonly health: HealthResource + readonly financial: FinancialResource + readonly typed: TypedAttributesResource constructor(options: ThinkFleetMemoryOptions) { if (!options.apiKey) { @@ -83,5 +87,7 @@ export class ThinkFleetMemory { this.alerts = new AlertsResource(http) this.compliance = new ComplianceResource(http) this.health = new HealthResource(http) + this.financial = new FinancialResource(http) + this.typed = new TypedAttributesResource(http) } } diff --git a/src/index.ts b/src/index.ts index b94c450..ef63ba2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -42,6 +42,20 @@ export type { PollEventsParams, } from './resources/events.js' +export { TypedAttributesResource } from './resources/typed.js' +export type { + AttributeDataType, + ObservationStatus, + AttributeDef, + RegisterAttributeRequest, + TypedObservationInput, + TypedObservation, + IngestReport, + Accumulator, + QueryObservationsParams, + AccumulatorParams, +} from './resources/typed.js' + export { ComplianceResource } from './resources/compliance.js' export type { ComplianceSubject, @@ -71,6 +85,29 @@ export type { } from './resources/consent.js' export { LatticeResource } from './resources/lattice.js' export { HealthResource } from './resources/health.js' +export { FinancialResource } from './resources/financial.js' + +// Types — financial +export type { + PriceInput, + FundamentalInput, + HoldingInput, + NewsInput, + TechnicalIndicators, + FundamentalSnapshot, + PortfolioPosition, + AssetAllocation, + PortfolioRisk, + FinancialProfile, + Direction, + FinancialSignal, + PredictFinancialResult, + ReconcileFinancialResult, + FinancialCalibrationBucket, + FinancialCalibrationReport, + PredictOptions, + CalibrationOptions, +} from './types/financial.js' // Types — health export type { @@ -146,6 +183,9 @@ export type { PredictRequest, PredictedEvent, PredictResult, + TargetKind, + PredictionTarget, + TargetPrediction, SubjectProfile, RiskIndicator, } from './types/lattice.js'