diff --git a/examples/README.md b/examples/README.md index 9c3b0c6..2628279 100644 --- a/examples/README.md +++ b/examples/README.md @@ -40,3 +40,49 @@ npm run demo:financial is what tunes reported confidence over time. - **Informational only — not investment advice.** Yahoo Finance data is provided as-is for demonstration. + +## `next-best-offer.ts` — which offer, when, and how it learns + +A runnable sample for the lifecycle question: **which offer is right for a +contact, when should it go out, and how does the system get better each time an +offer lands?** The engine owns memory, pattern-mining, prediction, and +calibration; **Claude** (`claude-opus-4-8`) owns the judgement call (which offer, +when); this app is the glue. + +| Step | What it does | Calls | +|------|--------------|-------| +| Learn | Ingest each contact's activity, mine behavior patterns | `tf.memory.observe` → `tf.lattice.mineMemories` | +| Understand | Read who the subject is + what they'll do next (the send-time signal) | `tf.lattice.getProfile` / `tf.lattice.predict` | +| Decide | Claude picks the offer + send time, grounded in the signal AND what has worked | `tf.learning.getEffectiveness` → Claude (`messages.parse`) → `tf.learning.recordDecision` | +| For a list | Rank a set of contacts by confidence / soonest send | loop the above | +| Act + learn | Record the realized outcome; every informing pattern is re-calibrated | `tf.learning.recordOutcome` → `tf.learning.getEffectiveness` | + +The decision records `informedBy` the patterns/predictions it leaned on, so when +the outcome comes in, **those** patterns' calibrated confidence moves — that's +the closed loop. Run it twice: the second Decide step sees the first run's +effectiveness and shifts toward what actually converted. + +### Run it + +```bash +npm install # pulls @anthropic-ai/sdk + zod (dev deps for this example) + +export THINKFLEET_API_KEY="sk-..." +export THINKFLEET_PROJECT_ID="..." +export ANTHROPIC_API_KEY="sk-ant-..." +export THINKFLEET_BASE_URL="https://memory.thinkfleet.ai" # optional +npm run demo:nbo +``` + +### Notes + +- **Domain stays in your app.** The engine never sees "offer" — the loop records + the generic `action_type="send_offer"` with the chosen offer id as the + `decision_type`, so `getEffectiveness({ groupBy: 'decision_type' })` rolls up + per offer. Nothing consumer-specific leaks into the engine. +- **Cold start is honest.** On the first run there's no outcome history, so + Claude reasons from the profile + predictions; effectiveness fills in as + outcomes accumulate and steers later runs. +- **Flobyte pieces** (Activepieces) can drive the same loop as a no-code flow — + the `recordDecision` / `recordOutcome` / `getEffectiveness` REST routes are the + same ones this SDK calls. This example is the code-first version of that flow. diff --git a/examples/next-best-offer.ts b/examples/next-best-offer.ts new file mode 100644 index 0000000..e75e6be --- /dev/null +++ b/examples/next-best-offer.ts @@ -0,0 +1,442 @@ +#!/usr/bin/env npx tsx +/** + * @thinkfleet/memory-sdk — Next Best Offer, end to end + * + * A working sample app for the question: *"which offer is right for this + * contact, and when is the right time to send it?"* — and, crucially, *how + * does the system get better at answering that every time an offer lands?* + * + * The loop it demonstrates: + * + * 1. LEARN feed each contact's activity into memory, mine behavior + * patterns (tf.memory.observe → tf.lattice.mineMemories) + * 2. UNDERSTAND read the subject back: who they are + what they'll do next + * (tf.lattice.getProfile / tf.lattice.predict) + * 3. DECIDE ask Claude to pick the offer + send time, grounded in that + * signal AND in what has actually worked before + * (tf.learning.getEffectiveness → Claude → tf.learning.recordDecision) + * 4. ACT+LEARN record the realized outcome; every pattern the decision leaned + * on is re-calibrated, so the next suggestion is smarter + * (tf.learning.recordOutcome → tf.learning.getEffectiveness) + * + * Division of responsibility: the ENGINE owns memory, pattern-mining, + * prediction, and calibration. CLAUDE owns the judgement call (which offer, + * when) — "your model, your key". This app is the thin glue between them. + * + * Usage: + * export THINKFLEET_API_KEY="sk-..." + * export THINKFLEET_PROJECT_ID="..." + * export THINKFLEET_BASE_URL="https://memory.thinkfleet.ai" # optional + * export ANTHROPIC_API_KEY="sk-ant-..." + * npx tsx examples/next-best-offer.ts + * + * Requires (dev): @anthropic-ai/sdk, zod → npm install + */ + +import Anthropic from '@anthropic-ai/sdk' +import { zodOutputFormat } from '@anthropic-ai/sdk/helpers/zod' +import { z } from 'zod' + +import { ThinkFleetMemory } from '../src/index.js' + +// ─── The offer catalog (domain data your app owns) ─────────────────── +// +// The engine is domain-agnostic — it never sees "offer" as a concept. Your +// app owns the catalog; the decision loop only records the generic +// action_type="send_offer" and the chosen offer id as the decision_type, so +// effectiveness rolls up per offer. + +interface Offer { + id: string + label: string + description: string + bestFor: string +} + +const OFFERS: Offer[] = [ + { + id: 'winback_20pct', + label: '20% win-back', + description: '20% off the next order, 7-day expiry.', + bestFor: 'Lapsing customers who used to be regular but have gone quiet.', + }, + { + id: 'loyalty_perk', + label: 'Loyalty perk', + description: 'Free add-on / priority service, no discount.', + bestFor: 'Engaged, high-frequency customers — reward without eroding margin.', + }, + { + id: 'replenish_reminder', + label: 'Replenishment nudge', + description: 'A timely "time to reorder?" nudge, no discount.', + bestFor: 'Customers on a predictable repurchase cadence.', + }, + { + id: 'bundle_upsell', + label: 'Bundle upsell', + description: 'A complementary product bundle at a small saving.', + bestFor: 'Customers with a clear category preference and room to expand basket.', + }, +] + +// ─── Types for the contact activity we seed ────────────────────────── + +interface Activity { + activityType: string // "order_placed" | "site_visit" | "email_open" | ... + content: string + occurredAt: string // ISO + metadata?: Record +} + +interface Contact { + externalId: string + activity: Activity[] +} + +const SUBJECT = (externalId: string) => ({ kind: 'contact', externalId }) + +// ─── 1. LEARN — ingest activity + mine patterns ────────────────────── + +async function learnAboutContact(tf: ThinkFleetMemory, c: Contact): Promise { + for (const a of c.activity) { + await tf.memory.observe({ + subject: SUBJECT(c.externalId), + content: a.content, + activityType: a.activityType, + occurredAt: a.occurredAt, + metadata: a.metadata, + }) + } + // Mine this subject's activity into behavior patterns (cadence, RFM, + // entity preference, lapsing risk, …). Idempotent + subject-scoped. + await tf.lattice.mineMemories({ subject: SUBJECT(c.externalId) }) + console.log(` · learned ${c.activity.length} events for ${c.externalId}`) +} + +// ─── 2. UNDERSTAND — read the subject back ─────────────────────────── + +interface Signal { + externalId: string + profile: unknown + predictions: Array<{ + patternId: string + description: string + expectedAt: string + confidence: number + }> + abstained: boolean + effectiveness: Array<{ + groupKey: string + n: number + successRate: number + avgReward: number + confidence: number + }> +} + +async function gatherSignal(tf: ThinkFleetMemory, externalId: string): Promise { + const subject = SUBJECT(externalId) + + // Who is this subject? (RFM segment, cadence, top entity, risk indicators) + const profile = await tf.lattice.getProfile(subject).catch(() => null) + + // What will they do next, and when? Pattern projection → the send-time signal. + const predictResult = await tf.lattice.predict({ subject, horizonDays: 30 }) + + // What has actually worked, per offer? The closed-loop signal — empty on the + // very first run, then it grows as outcomes come in. + const effectiveness = await tf.learning + .getEffectiveness({ groupBy: 'decision_type', minSupport: 1 }) + .catch(() => []) + + return { + externalId, + profile, + predictions: (predictResult.predictions ?? []).map((p) => ({ + patternId: p.patternId, + description: p.description, + expectedAt: p.expectedAt, + confidence: p.confidence, + })), + abstained: predictResult.abstained ?? false, + effectiveness, + } +} + +// ─── 3. DECIDE — Claude picks the offer + send time ────────────────── + +const DecisionSchema = z.object({ + offerId: z.string().describe('The chosen offer id from the catalog.'), + sendAtIso: z + .string() + .describe('When to send, ISO-8601. Time the offer to land just before the predicted next engagement.'), + sendTimeReason: z.string().describe('One sentence on why that send time.'), + rationale: z.string().describe('Two or three sentences justifying the offer choice from the signal.'), + confidence: z.number().describe('0..1 — how confident this is the right call.'), +}) +type OfferDecision = z.infer + +const SYSTEM_PROMPT = `You are a lifecycle-marketing strategist. Given one contact's \ +behavioral profile, the engine's prediction of their next engagement, and the historical \ +effectiveness of each offer type, choose the SINGLE best offer to send and the best time \ +to send it. + +Rules: +- Choose exactly one offer id from the catalog provided. +- Time the send to land shortly before the predicted next engagement, when there is one. +- Weigh historical effectiveness: prefer offers with a higher observed success rate and \ +average reward once there is enough evidence (n). With little or no evidence, reason from \ +the profile and predictions instead. +- If the engine abstained (not enough signal), say so in the rationale and pick the safest \ +broadly-applicable offer. +- Be concise and specific. Do not invent facts not present in the signal.` + +async function decideOffer(anthropic: Anthropic, signal: Signal): Promise { + const userContent = [ + `Contact: ${signal.externalId}`, + ``, + `OFFER CATALOG:`, + ...OFFERS.map((o) => `- ${o.id} (${o.label}): ${o.description} Best for: ${o.bestFor}`), + ``, + `PROFILE: ${JSON.stringify(signal.profile ?? 'none', null, 2)}`, + ``, + signal.abstained + ? `PREDICTIONS: engine ABSTAINED — not enough signal to predict next engagement.` + : `PREDICTED NEXT ENGAGEMENTS (soonest first):\n` + + signal.predictions + .map((p) => `- ${p.expectedAt} — ${p.description} (confidence ${p.confidence.toFixed(2)})`) + .join('\n'), + ``, + signal.effectiveness.length + ? `WHAT HAS WORKED (per offer, from realized outcomes):\n` + + signal.effectiveness + .map( + (e) => + `- ${e.groupKey}: ${(e.successRate * 100).toFixed(0)}% success over n=${e.n} ` + + `(avg reward ${e.avgReward.toFixed(2)}, calibrated ${(e.confidence * 100).toFixed(0)}%)`, + ) + .join('\n') + : `WHAT HAS WORKED: no outcome history yet — this is a cold start.`, + ``, + `Today is ${new Date().toISOString()}. Choose the offer and send time.`, + ].join('\n') + + const resp = await anthropic.messages.parse({ + model: 'claude-opus-4-8', + max_tokens: 4000, + thinking: { type: 'adaptive' }, + system: SYSTEM_PROMPT, + messages: [{ role: 'user', content: userContent }], + output_config: { format: zodOutputFormat(DecisionSchema) }, + }) + + if (!resp.parsed_output) { + throw new Error(`Claude did not return a parseable decision (stop_reason=${resp.stop_reason})`) + } + return resp.parsed_output +} + +// ─── Suggest for one contact: understand → decide → record ─────────── + +interface Suggestion { + externalId: string + decisionId: string + offer: Offer + sendAtIso: string + rationale: string + confidence: number +} + +async function suggestForContact( + tf: ThinkFleetMemory, + anthropic: Anthropic, + externalId: string, +): Promise { + const signal = await gatherSignal(tf, externalId) + const decision = await decideOffer(anthropic, signal) + const offer = OFFERS.find((o) => o.id === decision.offerId) ?? OFFERS[0] + + // Record the decision, linking it to the patterns/predictions that informed + // it. When we later record an outcome, THOSE patterns get re-calibrated — + // that's what closes the loop. + const { decision: recorded } = await tf.learning.recordDecision({ + subject: SUBJECT(externalId), + actor: 'agent:next-best-offer', + decisionType: offer.id, // effectiveness rolls up per offer + actionType: 'send_offer', + policy: 'next-best-offer-v1', + informedBy: signal.predictions.map((p) => ({ memoryId: p.patternId, refType: 'pattern' })), + params: { offerId: offer.id, sendAt: decision.sendAtIso }, + status: 'proposed', + // Idempotency: one proposed offer per contact per day. + idempotencyKey: `nbo:${externalId}:${decision.sendAtIso.slice(0, 10)}`, + }) + + return { + externalId, + decisionId: recorded!.decisionId, + offer, + sendAtIso: decision.sendAtIso, + rationale: decision.rationale, + confidence: decision.confidence, + } +} + +// ─── Suggest for a list: rank by confidence ────────────────────────── + +async function suggestForList( + tf: ThinkFleetMemory, + anthropic: Anthropic, + externalIds: string[], +): Promise { + const suggestions: Suggestion[] = [] + for (const id of externalIds) { + // Sequential to keep prompt-cache warm and stay under rate limits; for a + // large list, batch with a concurrency pool. + suggestions.push(await suggestForContact(tf, anthropic, id)) + } + // Rank: who should we act on first? Highest-confidence, soonest send. + return suggestions.sort( + (a, b) => b.confidence - a.confidence || a.sendAtIso.localeCompare(b.sendAtIso), + ) +} + +// ─── 4. ACT + LEARN — record the realized outcome ──────────────────── + +async function recordResult( + tf: ThinkFleetMemory, + s: Suggestion, + result: 'success' | 'failure' | 'partial', + reward: number, +): Promise { + const { updates } = await tf.learning.recordOutcome({ + decisionId: s.decisionId, + subject: SUBJECT(s.externalId), + outcomeType: 'conversion', + result, + reward, + idempotencyKey: `outcome:${s.decisionId}`, + }) + const moved = updates + .map((u) => `${u.refId.slice(0, 8)} ${u.priorConfidence.toFixed(2)}→${u.posteriorConfidence.toFixed(2)}`) + .join(', ') + console.log( + ` · ${s.externalId}: ${s.offer.id} → ${result} (reward ${reward})` + + (moved ? ` | recalibrated: ${moved}` : ''), + ) +} + +// ─── Demo data: three contacts with distinct behavior ──────────────── + +function daysAgo(n: number): string { + return new Date(Date.now() - n * 86_400_000).toISOString() +} + +const CONTACTS: Contact[] = [ + { + // Regular-then-quiet → a win-back candidate. + externalId: 'sarah-lapsing', + activity: [ + { activityType: 'order_placed', content: 'Order #1 — $42 pizza', occurredAt: daysAgo(90) }, + { activityType: 'order_placed', content: 'Order #2 — $38 pizza', occurredAt: daysAgo(76) }, + { activityType: 'order_placed', content: 'Order #3 — $45 pizza', occurredAt: daysAgo(62) }, + { activityType: 'order_placed', content: 'Order #4 — $40 pizza', occurredAt: daysAgo(48) }, + { activityType: 'site_visit', content: 'Browsed menu, did not order', occurredAt: daysAgo(20) }, + ], + }, + { + // Frequent + steady → reward, don't discount. + externalId: 'mike-loyal', + activity: [ + { activityType: 'order_placed', content: 'Order — $30', occurredAt: daysAgo(21) }, + { activityType: 'order_placed', content: 'Order — $34', occurredAt: daysAgo(14) }, + { activityType: 'order_placed', content: 'Order — $28', occurredAt: daysAgo(7) }, + { activityType: 'email_open', content: 'Opened weekly newsletter', occurredAt: daysAgo(2) }, + ], + }, + { + // Predictable monthly cadence → replenishment timing play. + externalId: 'ana-replenish', + activity: [ + { activityType: 'order_placed', content: 'Coffee beans 1kg — $24', occurredAt: daysAgo(88) }, + { activityType: 'order_placed', content: 'Coffee beans 1kg — $24', occurredAt: daysAgo(58) }, + { activityType: 'order_placed', content: 'Coffee beans 1kg — $24', occurredAt: daysAgo(29) }, + ], + }, +] + +// ─── Main ──────────────────────────────────────────────────────────── + +async function main() { + const apiKey = process.env.THINKFLEET_API_KEY + const projectId = process.env.THINKFLEET_PROJECT_ID + if (!apiKey || !projectId) { + console.error('Set THINKFLEET_API_KEY and THINKFLEET_PROJECT_ID (and ANTHROPIC_API_KEY).') + process.exit(1) + } + + const tf = new ThinkFleetMemory({ + apiKey, + projectId, + baseUrl: process.env.THINKFLEET_BASE_URL, + }) + const anthropic = new Anthropic() // reads ANTHROPIC_API_KEY + + console.log('\n① LEARN — ingest activity + mine patterns') + for (const c of CONTACTS) await learnAboutContact(tf, c) + + console.log('\n② + ③ SUGGEST for one contact (understand → decide → record)') + const first = await suggestForContact(tf, anthropic, 'sarah-lapsing') + console.log(` → ${first.externalId}: send "${first.offer.label}" at ${first.sendAtIso}`) + console.log(` confidence ${first.confidence.toFixed(2)} · ${first.rationale}`) + + console.log('\n④ SUGGEST for the whole list, ranked') + const ranked = await suggestForList( + tf, + anthropic, + CONTACTS.map((c) => c.externalId), + ) + ranked.forEach((s, i) => + console.log( + ` ${i + 1}. ${s.externalId.padEnd(16)} ${s.offer.label.padEnd(20)} ` + + `conf ${s.confidence.toFixed(2)} send ${s.sendAtIso.slice(0, 10)}`, + ), + ) + + console.log('\n⑤ ACT + LEARN — record outcomes; the loop calibrates') + // Simulate: the win-back converted well, the loyalty perk landed, the + // replenishment nudge got no response. In production these come from your + // order/CRM system, attributed back to the decision id. + const outcomes: Record = { + 'sarah-lapsing': ['success', 42], + 'mike-loyal': ['success', 30], + 'ana-replenish': ['failure', 0], + } + for (const s of ranked) { + const [result, reward] = outcomes[s.externalId] ?? ['partial', 0] + await recordResult(tf, s, result, reward) + } + + console.log('\n⑥ WHAT WORKED — effectiveness now has evidence to steer the next run') + const eff = await tf.learning.getEffectiveness({ groupBy: 'decision_type', minSupport: 1 }) + if (!eff.length) { + console.log(' (no rows yet — run again so more outcomes accumulate)') + } else { + for (const e of eff) { + console.log( + ` ${e.groupKey.padEnd(20)} success ${(e.successRate * 100).toFixed(0)}% ` + + `avg reward ${e.avgReward.toFixed(2)} n=${e.n} calibrated ${(e.confidence * 100).toFixed(0)}%`, + ) + } + } + console.log( + '\nRun it again: the DECIDE step now sees this effectiveness and shifts ' + + 'toward what actually converted.\n', + ) +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) diff --git a/package-lock.json b/package-lock.json index 24a80f8..cb3e9cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,23 +1,56 @@ { "name": "@thinkfleet/memory-sdk", - "version": "0.1.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@thinkfleet/memory-sdk", - "version": "0.1.0", + "version": "0.3.0", "license": "MIT", "devDependencies": { + "@anthropic-ai/sdk": "^0.68.0", "@types/node": "^25.9.1", "tsup": "^8.0.0", "tsx": "^4.21.0", - "typescript": "^5.3.0" + "typescript": "^5.3.0", + "zod": "^3.25.0" }, "engines": { "node": ">=18" } }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.68.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.68.0.tgz", + "integrity": "sha512-SMYAmbbiprG8k1EjEPMTwaTqssDT7Ae+jxcR5kWXiqTlbwMR2AthXtscEVWOHkRfyAV5+y3PFYTJRNa3OJWIEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", @@ -1070,6 +1103,20 @@ "node": ">=10" } }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -1403,6 +1450,13 @@ "tree-kill": "cli.js" } }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "dev": true, + "license": "MIT" + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -1993,6 +2047,16 @@ "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "dev": true, "license": "MIT" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 8a68ce2..727c3eb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@thinkfleet/memory-sdk", - "version": "0.2.0", + "version": "0.3.0", "description": "TypeScript SDK for memory.thinkfleet.ai — admin + project memory CRUD, semantic search, feedback, and Lattice behavioral patterns", "type": "module", "main": "./dist/index.cjs", @@ -31,13 +31,16 @@ "test:integration": "tsx test-app.ts", "seed": "tsx scripts/seed-memory.ts", "seed:lattice": "tsx scripts/seed-lattice.ts", - "demo:financial": "tsx examples/financial-demo.ts" + "demo:financial": "tsx examples/financial-demo.ts", + "demo:nbo": "tsx examples/next-best-offer.ts" }, "devDependencies": { + "@anthropic-ai/sdk": "^0.68.0", "@types/node": "^25.9.1", "tsup": "^8.0.0", "tsx": "^4.21.0", - "typescript": "^5.3.0" + "typescript": "^5.3.0", + "zod": "^3.25.0" }, "keywords": [ "thinkfleet", diff --git a/proto/coverage.json b/proto/coverage.json index a7b2a3a..e9664d0 100644 --- a/proto/coverage.json +++ b/proto/coverage.json @@ -26,7 +26,11 @@ "ListAuditEvents": "compliance.listAuditEvents", "ListPacks": "compliance.listPacks", "GetCohort": "lattice.getCohort", - "PredictByCohort": "lattice.predictByCohort" + "PredictByCohort": "lattice.predictByCohort", + "RecordDecision": "learning.recordDecision", + "RecordOutcome": "learning.recordOutcome", + "GetOutcomes": "learning.getOutcomes", + "GetEffectiveness": "learning.getEffectiveness" }, "internal": { "ResolveOrCreateEntity": "graph primitive; entity resolution runs server-side during extraction", diff --git a/proto/memory.proto b/proto/memory.proto index dc1b3a9..1c667b1 100644 --- a/proto/memory.proto +++ b/proto/memory.proto @@ -183,6 +183,27 @@ service Memory { // provenance (which cohort members + which patterns // contributed). rpc PredictByCohort(PredictByCohortRequest) returns (PredictByCohortResult); + + // ─── Outcome loop (closed-loop learning) ──────────────────────── + // + // The decision → action → outcome causal primitive. RecordDecision + // logs a choice an actor made, with provenance links to the + // patterns/predictions that informed it (credit assignment). + // RecordOutcome logs the realized result and, on write, re-weights + // the online calibrated confidence of every ref the decision was + // informed_by (Beta-Binomial posterior). GetOutcomes lists the + // linked records for a subject or scope; GetEffectiveness rolls + // "what worked" up per action_type / decision_type / policy. + // + // Domain-agnostic by construction — subject/decision/action/ + // outcome/reward only, never a consumer-domain noun. Decisions and + // outcomes are stored as MemoryItem rows (kind="decision" / + // "outcome") so embeddings, retrieval, and the GDPR subject cascade + // cover them for free. + rpc RecordDecision(RecordDecisionRequest) returns (RecordDecisionResponse); + rpc RecordOutcome(RecordOutcomeRequest) returns (RecordOutcomeResponse); + rpc GetOutcomes(GetOutcomesRequest) returns (GetOutcomesResponse); + rpc GetEffectiveness(GetEffectivenessRequest) returns (GetEffectivenessResponse); } // ─── Common ───────────────────────────────────────────────────────── @@ -1049,3 +1070,141 @@ message PredictByCohortResult { string generated_at = 4; uint64 duration_ms = 5; } + +// ─── Outcome loop (closed-loop learning) ──────────────────────────── +// +// The decision → action → outcome causal chain plus online calibration. +// A Decision records a choice, carrying provenance links to the +// patterns/predictions that informed it; an Outcome records the realized +// result and, on write, re-weights the calibrated confidence of every +// ref the decision was informed_by. Both are persisted as MemoryItem +// rows (kind="decision" / "outcome") with the subject on metadata.subject +// so retrieval, embeddings, and the GDPR subject cascade cover them for +// free. Subject reuses the existing `Subject` message ({kind, +// external_id}). Timestamps are RFC3339 strings (codebase convention); +// the CLIENT supplies them so the engine stays free of wall-clock deps, +// and the engine fills now() only when a field is left blank. + +// A causal input to a decision — the pattern/prediction/observation the +// actor was reacting to. `weight` enables credit assignment across +// multiple inputs; 0/unset is treated as 1.0. +message ProvenanceRef { + string memory_id = 1; // pattern/prediction/observation id that informed the decision + string ref_type = 2; // "pattern" | "prediction" | "observation" | "collective" + double weight = 3; // credit-assignment weight; 0/unset ⇒ 1.0 +} + +message RecordDecisionRequest { + Subject subject = 1; + string actor = 2; // "agent:copilot" | "human:owner" | "policy:winback-v1" + string decision_type = 3; // generic label: "offer" | "outreach" | "escalation" + string policy = 4; // optional policy id/version + repeated ProvenanceRef informed_by = 5; // causal inputs (credit assignment) + string action_type = 6; // "send_message" | "apply_discount" | ... + map params = 7; // opaque action params + string status = 8; // "proposed" | "executed" | "skipped" + string occurred_at = 9; // CLIENT RFC3339 timestamp; engine fills now() when blank + map metadata = 10; + // Idempotency: re-sending the same key returns the existing record + // without creating a duplicate. + string idempotency_key = 11; + // Tenancy — normally injected from gRPC metadata; override for s2s callers. + optional string platform_id = 20; + optional string project_id = 21; +} + +// The persisted decision. Returned in full (not just the id) so an +// idempotent replay round-trips the same record. +message DecisionRecord { + string decision_id = 1; + Subject subject = 2; + string actor = 3; + string decision_type = 4; + string policy = 5; + repeated ProvenanceRef informed_by = 6; + string action_type = 7; + string status = 8; + string occurred_at = 9; // RFC3339 + string created = 10; // RFC3339 +} + +message RecordDecisionResponse { + DecisionRecord decision = 1; +} + +message RecordOutcomeRequest { + string decision_id = 1; // links back to the decision + Subject subject = 2; + string outcome_type = 3; // "conversion" | "engagement" | "no_response" + string result = 4; // "success" | "failure" | "partial" + double reward = 5; // numeric signal (revenue, 0/1, points…) + string realized_at = 6; // CLIENT RFC3339 timestamp; engine fills now() when blank + int64 attribution_window_secs = 7; // how long after the decision this outcome counts + map metadata = 8; + string idempotency_key = 9; + optional string platform_id = 20; + optional string project_id = 21; +} + +// One re-weighted ref after an outcome folds into calibration. Surfaces +// exactly which informing refs moved and by how much (transparency). +message CalibrationUpdate { + string ref_id = 1; + string ref_type = 2; + double prior_confidence = 3; + double posterior_confidence = 4; + int64 hits = 5; + int64 misses = 6; +} + +message RecordOutcomeResponse { + string outcome_id = 1; + repeated CalibrationUpdate updates = 2; // which refs were re-weighted +} + +message OutcomeRecord { + string outcome_id = 1; + string decision_id = 2; + Subject subject = 3; + string decision_type = 4; // denormalized from the linked decision + string action_type = 5; // denormalized from the linked decision + string outcome_type = 6; + string result = 7; + double reward = 8; + string occurred_at = 9; // the decision's occurred_at, RFC3339 + string realized_at = 10; // RFC3339 +} + +message GetOutcomesRequest { + Subject subject = 1; // optional; omit for scope-wide + string decision_type = 2; // optional filter + string action_type = 3; // optional filter + uint32 limit = 4; // default 100, clamped [1, 1000] + optional string platform_id = 20; + optional string project_id = 21; +} + +message GetOutcomesResponse { + repeated OutcomeRecord outcomes = 1; +} + +// One "what worked" aggregation row. Per-subject and per-scope only — +// cross-tenant aggregation + K-anonymity are the consumer app's job. +message EffectivenessRow { + string group_key = 1; + int64 n = 2; // support (outcome count in this group) + double success_rate = 3; // fraction with result="success" + double avg_reward = 4; + double confidence = 5; // Beta-Binomial posterior mean of the success rate +} + +message GetEffectivenessRequest { + string group_by = 1; // "action_type" | "decision_type" | "policy" | "pattern_kind" + uint32 min_support = 2; // return only groups with n >= min_support + optional string platform_id = 20; + optional string project_id = 21; +} + +message GetEffectivenessResponse { + repeated EffectivenessRow rows = 1; +} diff --git a/src/client.ts b/src/client.ts index 6be5745..b3e74e7 100644 --- a/src/client.ts +++ b/src/client.ts @@ -8,6 +8,7 @@ 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 { LearningResource } from './resources/learning.js' import { MemoryResource } from './resources/memory.js' import { TypedAttributesResource } from './resources/typed.js' @@ -54,6 +55,7 @@ export interface ThinkFleetMemoryOptions { export class ThinkFleetMemory { readonly memory: MemoryResource readonly lattice: LatticeResource + readonly learning: LearningResource readonly behaviors: BehaviorsResource readonly context: ContextResource readonly events: EventsResource @@ -84,6 +86,7 @@ export class ThinkFleetMemory { this.memory = new MemoryResource(http) this.lattice = new LatticeResource(http) + this.learning = new LearningResource(http) this.behaviors = new BehaviorsResource(http) this.context = new ContextResource(http) this.events = new EventsResource(http) diff --git a/src/index.ts b/src/index.ts index bd29ffb..0475a34 100644 --- a/src/index.ts +++ b/src/index.ts @@ -90,6 +90,21 @@ export type { DiscoveredBehavior, DiscoverResult, } from './resources/behaviors.js' +export { LearningResource } from './resources/learning.js' +export type { + ProvenanceRef, + RecordDecisionInput, + DecisionRecord, + RecordDecisionResult, + RecordOutcomeInput, + CalibrationUpdate, + RecordOutcomeResult, + OutcomeRecord, + GetOutcomesParams, + EffectivenessGroupBy, + GetEffectivenessParams, + EffectivenessRow, +} from './resources/learning.js' export { HealthResource } from './resources/health.js' export { FinancialResource } from './resources/financial.js' diff --git a/src/resources/learning.ts b/src/resources/learning.ts new file mode 100644 index 0000000..13f5129 --- /dev/null +++ b/src/resources/learning.ts @@ -0,0 +1,239 @@ +import type { HttpClient } from '../core/http-client.js' +import type { RequestOptions } from '../core/types.js' +import type { Subject } from '../types/lattice.js' + +/** + * A causal input to a decision — the pattern/prediction/observation the + * actor was reacting to. `weight` splits credit across multiple inputs. + */ +export interface ProvenanceRef { + /** Memory id of the pattern/prediction/observation that informed the decision. */ + memoryId: string + /** What kind of memory this ref points at. Defaults to "pattern". */ + refType?: 'pattern' | 'prediction' | 'observation' | 'collective' | (string & {}) + /** Credit-assignment weight; 0/unset is treated as 1.0. */ + weight?: number +} + +export interface RecordDecisionInput { + /** Who/what the decision is about. */ + subject: Subject + /** Who made it: "agent:copilot" | "human:owner" | "policy:winback-v1". */ + actor?: string + /** Generic label: "offer" | "outreach" | "escalation". */ + decisionType?: string + /** Optional policy id/version. */ + policy?: string + /** The patterns/predictions that informed the decision (credit assignment). */ + informedBy?: ProvenanceRef[] + /** The executed effect: "send_message" | "apply_discount" | ... */ + actionType?: string + /** Opaque action params. */ + params?: Record + /** "proposed" | "executed" | "skipped". Defaults to "executed". */ + status?: 'proposed' | 'executed' | 'skipped' | (string & {}) + /** Client RFC3339 timestamp; the engine fills now() when omitted. */ + occurredAt?: string + metadata?: Record + /** Re-sending the same key returns the existing decision, no duplicate. */ + idempotencyKey?: string +} + +export interface DecisionRecord { + decisionId: string + subject: Subject | null + actor: string + decisionType: string + policy: string + informedBy: ProvenanceRef[] + actionType: string + status: string + occurredAt: string + created: string +} + +export interface RecordDecisionResult { + decision: DecisionRecord | null +} + +export interface RecordOutcomeInput { + /** The decision this outcome resulted from. */ + decisionId: string + /** Optional — defaults to the linked decision's subject. */ + subject?: Subject + /** "conversion" | "engagement" | "no_response". */ + outcomeType?: string + /** "success" | "failure" | "partial". */ + result: 'success' | 'failure' | 'partial' | (string & {}) + /** Numeric reward signal (revenue, 0/1, points…). */ + reward?: number + /** Client RFC3339 timestamp; the engine fills now() when omitted. */ + realizedAt?: string + /** How long after the decision this outcome still counts, in seconds. */ + attributionWindowSecs?: number + metadata?: Record + /** Exactly-once guard: a replayed key won't double-count calibration. */ + idempotencyKey?: string +} + +/** One informing ref re-weighted by an outcome (before/after confidence). */ +export interface CalibrationUpdate { + refId: string + refType: string + priorConfidence: number + posteriorConfidence: number + hits: number + misses: number +} + +export interface RecordOutcomeResult { + outcomeId: string + /** Which informing refs were re-weighted, and by how much. */ + updates: CalibrationUpdate[] +} + +export interface OutcomeRecord { + outcomeId: string + decisionId: string + subject: Subject | null + decisionType: string + actionType: string + outcomeType: string + result: string + reward: number + occurredAt: string + realizedAt: string +} + +export interface GetOutcomesParams { + /** Restrict to one subject; omit for scope-wide. */ + subject?: Subject + decisionType?: string + actionType?: string + /** Default 100, clamped [1, 1000]. */ + limit?: number +} + +export type EffectivenessGroupBy = + | 'action_type' + | 'decision_type' + | 'policy' + | 'pattern_kind' + +export interface GetEffectivenessParams { + /** Dimension to roll up by. Defaults to "action_type". */ + groupBy?: EffectivenessGroupBy + /** Return only groups with at least this many outcomes. */ + minSupport?: number +} + +/** One "what worked" aggregation row. */ +export interface EffectivenessRow { + groupKey: string + /** Support: outcome count in this group. */ + n: number + /** Fraction with result="success". */ + successRate: number + avgReward: number + /** Beta-Binomial posterior mean of the success rate. */ + confidence: number +} + +/** + * Learning — the closed-loop **decision → action → outcome** primitive. + * + * Where `tf.lattice.predict` answers "what will happen?", the learning + * loop answers **"did acting on it work?"**. Record a decision (with links + * to the patterns/predictions that informed it), record its realized + * outcome, and every informing pattern's calibrated confidence moves + * toward what actually happened. `getEffectiveness` rolls "what worked" up + * per action_type / decision_type / policy / pattern_kind. + * + * Domain-agnostic by design — subject/decision/action/outcome/reward only. + * + * @example + * ```ts + * const { decision } = await tf.learning.recordDecision({ + * subject: { kind: 'contact', externalId: 'sarah' }, + * actor: 'policy:winback-v1', + * decisionType: 'offer', + * actionType: 'apply_discount', + * informedBy: [{ memoryId: patternId, refType: 'pattern' }], + * params: { pct: '15' }, + * }) + * + * const { updates } = await tf.learning.recordOutcome({ + * decisionId: decision!.decisionId, + * outcomeType: 'conversion', + * result: 'success', + * reward: 84.0, + * }) + * // updates[i].posteriorConfidence shows how each informing pattern moved. + * + * const rows = await tf.learning.getEffectiveness({ groupBy: 'action_type' }) + * ``` + */ +export class LearningResource { + constructor(private readonly http: HttpClient) {} + + /** Record a decision and its causal provenance. */ + async recordDecision( + body: RecordDecisionInput, + options?: RequestOptions, + ): Promise { + return this.http.post('/lattice/decisions', body, options) + } + + /** + * Record the realized outcome of a decision. Folds the result into the + * online calibrated confidence of every pattern the decision was + * informed_by, and returns the before/after for each. + */ + async recordOutcome( + body: RecordOutcomeInput, + options?: RequestOptions, + ): Promise { + return this.http.post('/lattice/outcomes', body, options) + } + + /** List recorded outcomes for a subject (or the whole scope), newest first. */ + async getOutcomes( + params: GetOutcomesParams = {}, + options?: RequestOptions, + ): Promise { + const query: Record = { + subjectKind: params.subject?.kind, + subjectExternalId: params.subject?.externalId, + decisionType: params.decisionType, + actionType: params.actionType, + limit: params.limit, + } + const r = await this.http.get<{ outcomes: OutcomeRecord[] }>( + '/lattice/outcomes', + query, + options, + ) + return r.outcomes + } + + /** + * "What worked" roll-up — success rate, average reward, and posterior + * confidence per group. Per-scope only; cross-tenant aggregation + + * K-anonymity are the consuming app's responsibility. + */ + async getEffectiveness( + params: GetEffectivenessParams = {}, + options?: RequestOptions, + ): Promise { + const query: Record = { + groupBy: params.groupBy, + minSupport: params.minSupport, + } + const r = await this.http.get<{ rows: EffectivenessRow[] }>( + '/lattice/effectiveness', + query, + options, + ) + return r.rows + } +}