Jaspers Screener: Screens US SEC registrants by fundamentals, holders and insiders, then reads filings against written criteria with cited quotes.
| 1 | // The screener's pure half: what the panel's state means, what goes to the server and what its |
| 2 | // answers hold, how a value reads and how a typed one is read back, and the text a model is given. |
| 3 | // The server (screener-mcp) owns the fields: their names, kinds, units and ops come from its guide, |
| 4 | // so there is no list of them here. No React and no SDK: it is imported by the view and read by the |
| 5 | // tests, which run under Node on their own. |
| 6 | |
| 7 | export type Kind = 'number' | 'text' | 'date' | 'list' |
| 8 | /** How a number field reads: raw USD, a fraction shown as a percentage, a year, or a plain number. */ |
| 9 | export type Unit = 'usd' | 'fraction' | 'year' | 'number' |
| 10 | |
| 11 | export const OPS = ['gt', 'gte', 'lt', 'lte', 'eq', 'ne', 'between', 'in', 'contains', 'is_null', 'not_null'] as const |
| 12 | export type Op = (typeof OPS)[number] |
| 13 | |
| 14 | export const VERDICTS = ['pass', 'fail', 'unclear', 'unverified'] as const |
| 15 | export type Verdict = (typeof VERDICTS)[number] |
| 16 | |
| 17 | /** One field a filter, a sort or a column may name, as the server's guide describes it. */ |
| 18 | export interface Field { |
| 19 | field: string |
| 20 | group: string |
| 21 | kind: Kind |
| 22 | unit: Unit | null |
| 23 | ops: Op[] |
| 24 | sortable: boolean |
| 25 | description: string |
| 26 | } |
| 27 | |
| 28 | /** The server's filter language: raw units, joined by AND. */ |
| 29 | export interface Filter { |
| 30 | field: string |
| 31 | op: Op |
| 32 | value?: number | string | (number | string)[] |
| 33 | } |
| 34 | |
| 35 | export interface Sort { |
| 36 | field: string |
| 37 | dir: 'asc' | 'desc' |
| 38 | } |
| 39 | |
| 40 | export interface Criterion { |
| 41 | id: string |
| 42 | question: string |
| 43 | } |
| 44 | |
| 45 | /** The qualitative run of this panel. Present with no id, it is a request to start one. */ |
| 46 | export interface Run { |
| 47 | id?: string |
| 48 | status?: 'running' | 'done' | 'error' |
| 49 | error?: string |
| 50 | } |
| 51 | |
| 52 | /** What the panel holds, and so what the orchestrator sets. */ |
| 53 | export interface State { |
| 54 | filters: Filter[] |
| 55 | /** The user's own list; empty for every company. */ |
| 56 | tickers: string[] |
| 57 | includeUnlisted: boolean |
| 58 | sort: Sort |
| 59 | page: number |
| 60 | columns: string[] |
| 61 | /** The server's session, written by the view from its first answer. */ |
| 62 | sessionId: string | null |
| 63 | criteria: Criterion[] |
| 64 | run: Run | null |
| 65 | /** Which verdicts the table shows once a run has results; empty for all. */ |
| 66 | verdicts: Verdict[] |
| 67 | /** The ticker whose evidence is open; empty for none. */ |
| 68 | open: string |
| 69 | } |
| 70 | |
| 71 | export const DEFAULT_SORT: Sort = { field: 'revenue', dir: 'desc' } |
| 72 | |
| 73 | /** The columns a new screener shows. The fields filtered and sorted on are shown whatever this says. */ |
| 74 | export const DEFAULT_COLUMNS = [ |
| 75 | 'revenue', |
| 76 | 'revenue_growth_yoy', |
| 77 | 'gross_margin', |
| 78 | 'operating_margin', |
| 79 | 'net_margin', |
| 80 | 'net_income', |
| 81 | 'free_cash_flow', |
| 82 | 'roe', |
| 83 | 'debt_to_equity', |
| 84 | 'insider_net_buy_value_6m', |
| 85 | 'inst_holders', |
| 86 | 'fiscal_year', |
| 87 | ] |
| 88 | |
| 89 | /** What the server allows in one run, for the editor to stop at. */ |
| 90 | export const MAX_CRITERIA = 8 |
| 91 | /** The most columns the panel's state takes. */ |
| 92 | export const MAX_COLUMNS = 40 |
| 93 | |
| 94 | // The guide's field rows. |
| 95 | |
| 96 | const KINDS: Kind[] = ['number', 'text', 'date', 'list'] |
| 97 | const UNITS: Unit[] = ['usd', 'fraction', 'year', 'number'] |
| 98 | |
| 99 | /** The rows of get_guide("fields"), read leniently: a row that names no field is dropped, a number field with no unit is a plain number. */ |
| 100 | export function readFields(rows: Record<string, unknown>[] | undefined): Field[] { |
| 101 | const fields: Field[] = [] |
| 102 | for (const row of rows ?? []) { |
| 103 | if (typeof row.field !== 'string' || !row.field) continue |
| 104 | const kind = KINDS.includes(row.kind as Kind) ? (row.kind as Kind) : 'number' |
| 105 | const unit = UNITS.includes(row.unit as Unit) ? (row.unit as Unit) : kind === 'number' ? 'number' : null |
| 106 | fields.push({ |
| 107 | field: row.field, |
| 108 | group: typeof row.group === 'string' ? row.group : '', |
| 109 | kind, |
| 110 | unit, |
| 111 | ops: Array.isArray(row.ops) ? row.ops.filter((op): op is Op => OPS.includes(op as Op)) : [], |
| 112 | sortable: typeof row.sortable === 'boolean' ? row.sortable : kind !== 'list', |
| 113 | description: typeof row.description === 'string' ? row.description : '', |
| 114 | }) |
| 115 | } |
| 116 | return fields |
| 117 | } |
| 118 | |
| 119 | /** The fields by group, in the guide's order. */ |
| 120 | export function groupFields(fields: Field[]): [string, Field[]][] { |
| 121 | const groups = new Map<string, Field[]>() |
| 122 | for (const field of fields) groups.set(field.group, [...(groups.get(field.group) ?? []), field]) |
| 123 | return [...groups.entries()] |
| 124 | } |
| 125 | |
| 126 | /** Parts of a field's name that do not read as words. */ |
| 127 | const WORDS: Record<string, string> = { |
| 128 | yoy: 'YoY', |
| 129 | cagr: 'CAGR', |
| 130 | roe: 'ROE', |
| 131 | roa: 'ROA', |
| 132 | eps: 'EPS', |
| 133 | fcf: 'FCF', |
| 134 | ocf: 'OCF', |
| 135 | sga: 'SG&A', |
| 136 | rnd: 'R&D', |
| 137 | ebitda: 'EBITDA', |
| 138 | sic: 'SIC', |
| 139 | cik: 'CIK', |
| 140 | hq: 'HQ', |
| 141 | lt: 'LT', |
| 142 | st: 'ST', |
| 143 | wa: 'avg', |
| 144 | inst: 'Inst.', |
| 145 | pct: '%', |
| 146 | '3y': '3Y', |
| 147 | } |
| 148 | |
| 149 | /** A field's name as a column header and a chip say it: revenue_growth_yoy is "Revenue growth YoY". */ |
| 150 | export function fieldLabel(field: string): string { |
| 151 | const words = field.split('_').map((word) => WORDS[word] ?? word) |
| 152 | const first = words[0] ?? '' |
| 153 | // A word that was replaced is already written the way it reads; any other opens with a capital. |
| 154 | if (first === field.split('_')[0]) words[0] = first.charAt(0).toUpperCase() + first.slice(1) |
| 155 | return words.join(' ') |
| 156 | } |
| 157 | |
| 158 | /** What every row carries whatever is asked for. Naming one as a column says nothing, so none is drawn twice and none is sent. */ |
| 159 | const IDENTITY = new Set(['cik', 'name', 'ticker', 'tickers', 'sic_description']) |
| 160 | |
| 161 | /** The columns the table draws after the company: the fields filtered on, the sort, then the ones asked for — the server's own rule. */ |
| 162 | export function shownColumns(filters: Filter[], sort: Sort, columns: string[]): string[] { |
| 163 | return [...new Set([...filters.map((f) => f.field), sort.field, ...columns])].filter((field) => !IDENTITY.has(field)) |
| 164 | } |
| 165 | |
| 166 | // Formatting. A cell says a number the way a screener does (B, M, K); a chip and the dialog say a |
| 167 | // threshold in the unit the user types it in, so what is typed and what is shown agree. |
| 168 | |
| 169 | /** A number with K, M, B or T behind it when that says it exactly, so what is shown reads back as the same value. */ |
| 170 | function scaled(value: number, thousands: boolean): string { |
| 171 | const a = Math.abs(value) |
| 172 | const sign = value < 0 ? '-' : '' |
| 173 | for (const [suffix, scale] of SCALES) { |
| 174 | if (a < scale || (suffix === 'K' && !thousands)) continue |
| 175 | const short = String(clean(a / scale)) |
| 176 | if (short.length <= 7) return `${sign}${short}${suffix}` |
| 177 | break |
| 178 | } |
| 179 | return `${sign}${clean(a)}` |
| 180 | } |
| 181 | |
| 182 | const SCALES: [string, number][] = [ |
| 183 | ['T', 1e12], |
| 184 | ['B', 1e9], |
| 185 | ['M', 1e6], |
| 186 | ['K', 1e3], |
| 187 | ] |
| 188 | |
| 189 | function money(value: number): string { |
| 190 | const a = Math.abs(value) |
| 191 | const sign = value < 0 ? '-' : '' |
| 192 | if (a >= 1e12) return `${sign}$${(a / 1e12).toFixed(2)}T` |
| 193 | if (a >= 1e9) return `${sign}$${(a / 1e9).toFixed(2)}B` |
| 194 | if (a >= 1e6) return `${sign}$${(a / 1e6).toFixed(1)}M` |
| 195 | if (a >= 1e3) return `${sign}$${(a / 1e3).toFixed(0)}K` |
| 196 | return `${sign}$${a.toFixed(2)}` |
| 197 | } |
| 198 | |
| 199 | function plain(value: number): string { |
| 200 | const a = Math.abs(value) |
| 201 | if (a >= 1e9) return `${(value / 1e9).toFixed(2)}B` |
| 202 | if (a >= 1e6) return `${(value / 1e6).toFixed(1)}M` |
| 203 | if (Number.isInteger(value)) return value.toLocaleString('en-US') |
| 204 | return value.toFixed(2) |
| 205 | } |
| 206 | |
| 207 | /** What one cell says. A value the company never reported is a dash, not a zero. */ |
| 208 | export function cellText(value: unknown, field?: Pick<Field, 'kind' | 'unit'>): string { |
| 209 | if (value === null || value === undefined) return '—' |
| 210 | if (Array.isArray(value)) return value.length > 0 ? value.join(' ') : '—' |
| 211 | if (typeof value === 'number') { |
| 212 | if (!Number.isFinite(value)) return '—' |
| 213 | if (field?.unit === 'usd') return money(value) |
| 214 | if (field?.unit === 'fraction') return `${(value * 100).toFixed(1)}%` |
| 215 | if (field?.unit === 'year') return String(value) |
| 216 | return plain(value) |
| 217 | } |
| 218 | return String(value) |
| 219 | } |
| 220 | |
| 221 | /** Growth and returns read faster with a sign on them; nothing else is coloured. */ |
| 222 | export function signOf(field: string, value: unknown): 'up' | 'down' | null { |
| 223 | if (!/growth|cagr|change|^ro[ae]$/.test(field)) return null |
| 224 | if (typeof value !== 'number' || !Number.isFinite(value) || value === 0) return null |
| 225 | return value > 0 ? 'up' : 'down' |
| 226 | } |
| 227 | |
| 228 | /** What the dialog writes beside a value, so the user knows what the number they type means. */ |
| 229 | export function unitHint(field: Pick<Field, 'kind' | 'unit'>): string { |
| 230 | if (field.kind === 'date') return 'YYYY-MM-DD' |
| 231 | if (field.kind !== 'number') return '' |
| 232 | if (field.unit === 'usd') return '$, with K M B' |
| 233 | if (field.unit === 'fraction') return '%' |
| 234 | if (field.unit === 'year') return 'year' |
| 235 | return 'K M B allowed' |
| 236 | } |
| 237 | |
| 238 | const SCALE: Record<string, number> = { k: 1e3, m: 1e6, b: 1e9, t: 1e12 } |
| 239 | |
| 240 | /** |
| 241 | * A value as the user types it, in the server's raw units, or null when it cannot be read: "500M" and |
| 242 | * "$2B" are dollars, "20" on a fraction is 0.2, a date is ISO. Text is taken as it is. |
| 243 | */ |
| 244 | export function parseValue(text: string, field: Pick<Field, 'kind' | 'unit'>): number | string | null { |
| 245 | const typed = text.trim() |
| 246 | if (!typed) return null |
| 247 | if (field.kind === 'date') return /^\d{4}-\d{2}-\d{2}$/.test(typed) ? typed : null |
| 248 | if (field.kind !== 'number') return typed |
| 249 | if (field.unit === 'year') return /^\d{4}$/.test(typed) ? Number(typed) : null |
| 250 | const match = /^(-)?\s*\$?\s*(-)?\s*(\d[\d,]*\.?\d*|\.\d+)\s*([kmbt])?\s*(%)?$/i.exec(typed) |
| 251 | if (!match) return null |
| 252 | const number = Number(match[3]!.replace(/,/g, '')) |
| 253 | if (!Number.isFinite(number)) return null |
| 254 | const fraction = field.unit === 'fraction' |
| 255 | // A percent sign belongs on a fraction and a scale does not; anywhere else it is the other way round. |
| 256 | if (fraction ? match[4] : match[5]) return null |
| 257 | const signed = match[1] || match[2] ? -number : number |
| 258 | return clean(fraction ? signed / 100 : signed * (SCALE[match[4]?.toLowerCase() ?? ''] ?? 1)) |
| 259 | } |
| 260 | |
| 261 | /** A raw value as the user would type it, so a filter reads in the dialog the way it was entered. */ |
| 262 | export function valueText(raw: number | string, field: Pick<Field, 'kind' | 'unit'> | undefined): string { |
| 263 | if (typeof raw !== 'number') return raw |
| 264 | if (field?.unit === 'fraction') return `${clean(raw * 100)}%` |
| 265 | if (field?.unit === 'year') return String(raw) |
| 266 | if (field?.unit === 'usd') return raw < 0 ? `-$${scaled(-raw, true)}` : `$${scaled(raw, true)}` |
| 267 | return scaled(raw, false) |
| 268 | } |
| 269 | |
| 270 | const OP_TEXT: Record<Op, string> = { |
| 271 | gt: '>', |
| 272 | gte: '≥', |
| 273 | lt: '<', |
| 274 | lte: '≤', |
| 275 | eq: '=', |
| 276 | ne: '≠', |
| 277 | between: 'between', |
| 278 | in: 'in', |
| 279 | contains: 'contains', |
| 280 | is_null: 'is empty', |
| 281 | not_null: 'has a value', |
| 282 | } |
| 283 | |
| 284 | /** An op as the dialog's list and a chip say it. */ |
| 285 | export function opText(op: Op): string { |
| 286 | return OP_TEXT[op] |
| 287 | } |
| 288 | |
| 289 | /** One filter in words: "Revenue ≥ $500M", "Gross margin 20% to 40%", "HQ state in TX, FL". */ |
| 290 | export function filterText(filter: Filter, field: Pick<Field, 'kind' | 'unit'> | undefined): string { |
| 291 | const label = fieldLabel(filter.field) |
| 292 | const say = (value: number | string): string => |
| 293 | typeof value === 'string' && field?.kind !== 'date' ? `"${value}"` : valueText(value, field) |
| 294 | const { op, value } = filter |
| 295 | if (op === 'is_null' || op === 'not_null') return `${label} ${OP_TEXT[op]}` |
| 296 | if (op === 'between' && Array.isArray(value) && value.length === 2) return `${label} ${say(value[0]!)} to ${say(value[1]!)}` |
| 297 | if (Array.isArray(value)) return `${label} ${OP_TEXT[op]} ${value.map((v) => valueText(v, field)).join(', ')}` |
| 298 | return `${label} ${OP_TEXT[op]} ${value === undefined ? '?' : say(value)}` |
| 299 | } |
| 300 | |
| 301 | /** A filter as the dialog edits it: what was typed, not yet read. */ |
| 302 | export interface Draft { |
| 303 | field: string |
| 304 | op: Op |
| 305 | a: string |
| 306 | b: string |
| 307 | } |
| 308 | |
| 309 | export function toDraft(filter: Filter, field: Pick<Field, 'kind' | 'unit'> | undefined): Draft { |
| 310 | const { value } = filter |
| 311 | const text = (v: number | string | undefined): string => (v === undefined ? '' : valueText(v, field)) |
| 312 | if (filter.op === 'between' && Array.isArray(value)) return { field: filter.field, op: filter.op, a: text(value[0]), b: text(value[1]) } |
| 313 | if (Array.isArray(value)) return { field: filter.field, op: filter.op, a: value.map(text).join(', '), b: '' } |
| 314 | return { field: filter.field, op: filter.op, a: text(value), b: '' } |
| 315 | } |
| 316 | |
| 317 | /** The filter a draft says, or a sentence about what is wrong with it. */ |
| 318 | export function fromDraft(draft: Draft, field: Field | undefined): { filter: Filter } | { error: string } { |
| 319 | if (!field) return { error: 'Pick a field.' } |
| 320 | if (!field.ops.includes(draft.op)) return { error: `${fieldLabel(field.field)} takes ${field.ops.map(opText).join(', ')}.` } |
| 321 | const base = { field: field.field, op: draft.op } |
| 322 | if (draft.op === 'is_null' || draft.op === 'not_null') return { filter: base } |
| 323 | const hint = unitHint(field) |
| 324 | const wrong = (text: string): { error: string } => ({ error: `Cannot read "${text}"${hint ? ` (${hint})` : ''}.` }) |
| 325 | if (draft.op === 'between') { |
| 326 | const low = parseValue(draft.a, field) |
| 327 | const high = parseValue(draft.b, field) |
| 328 | if (low === null) return wrong(draft.a) |
| 329 | if (high === null) return wrong(draft.b) |
| 330 | return { filter: { ...base, value: [low, high] } } |
| 331 | } |
| 332 | // A list field is asked "any of", whatever the op's name: the server takes a list there. |
| 333 | if (draft.op === 'in') { |
| 334 | const parts = draft.a.split(',').map((part) => part.trim()).filter(Boolean) |
| 335 | if (parts.length === 0) return { error: 'Give at least one value, separated by commas.' } |
| 336 | const values: (number | string)[] = [] |
| 337 | for (const part of parts) { |
| 338 | const value = parseValue(part, field) |
| 339 | if (value === null) return wrong(part) |
| 340 | values.push(value) |
| 341 | } |
| 342 | return { filter: { ...base, value: values } } |
| 343 | } |
| 344 | if (draft.op === 'contains') return draft.a.trim() ? { filter: { ...base, value: draft.a.trim() } } : { error: 'Give the text to look for.' } |
| 345 | const value = parseValue(draft.a, field) |
| 346 | return value === null ? wrong(draft.a) : { filter: { ...base, value } } |
| 347 | } |
| 348 | |
| 349 | /** Tickers as typed, as the SEC writes them: upper case, once each, split on commas, spaces and lines. */ |
| 350 | export function readTickers(text: string): string[] { |
| 351 | return [...new Set(text.split(/[\s,;]+/).map((t) => t.trim().toUpperCase()).filter(Boolean))] |
| 352 | } |
| 353 | |
| 354 | // The screen: what one call sends, and what its answer holds. |
| 355 | |
| 356 | /** What the server's session holds, as its last answer said it. */ |
| 357 | export interface Echo { |
| 358 | sessionId: string |
| 359 | filters: Filter[] |
| 360 | tickers: string[] |
| 361 | includeUnlisted: boolean |
| 362 | } |
| 363 | |
| 364 | /** A company a filter removed, as the server names it: its ticker and name, and the value of the filter's field that did not pass. */ |
| 365 | export interface Removed { |
| 366 | ticker: string | null |
| 367 | name: string | null |
| 368 | value: unknown |
| 369 | } |
| 370 | |
| 371 | /** A filter as the server applied it, with what the answer says of it. */ |
| 372 | export interface Applied extends Filter { |
| 373 | /** Companies of the universe that pass it on its own. */ |
| 374 | matches: number |
| 375 | /** Companies left after it and every filter before it; null from a server that does not say. */ |
| 376 | left: number | null |
| 377 | /** The first companies, in the table's order, that it removed from those left before it; null from a server that does not name them. */ |
| 378 | removed: Removed[] | null |
| 379 | } |
| 380 | |
| 381 | /** A screen_companies answer without its rows: the dataset's meta, read leniently. */ |
| 382 | export interface Screen { |
| 383 | sessionId: string | null |
| 384 | universe: number |
| 385 | count: number |
| 386 | countBefore: number | null |
| 387 | applied: Applied[] |
| 388 | tickers: string[] |
| 389 | unknownTickers: string[] |
| 390 | includeUnlisted: boolean |
| 391 | ready: boolean |
| 392 | max: number |
| 393 | } |
| 394 | |
| 395 | export function readScreen(meta: Record<string, unknown>): Screen | null { |
| 396 | if (typeof meta.count !== 'number') return null |
| 397 | const applied = Array.isArray(meta.applied_filters) ? meta.applied_filters.filter(isRecord) : [] |
| 398 | return { |
| 399 | sessionId: typeof meta.session_id === 'string' ? meta.session_id : null, |
| 400 | universe: number(meta.universe), |
| 401 | count: meta.count, |
| 402 | countBefore: typeof meta.count_before === 'number' ? meta.count_before : null, |
| 403 | applied: applied |
| 404 | .filter((f) => typeof f.field === 'string' && OPS.includes(f.op as Op)) |
| 405 | .map((f) => ({ |
| 406 | field: f.field as string, |
| 407 | op: f.op as Op, |
| 408 | ...(f.value === undefined || f.value === null ? {} : { value: f.value as Filter['value'] }), |
| 409 | matches: number(f.matches), |
| 410 | left: typeof f.left === 'number' ? f.left : null, |
| 411 | removed: Array.isArray(f.removed) ? f.removed.filter(isRecord).map(readRemoved) : null, |
| 412 | })), |
| 413 | tickers: strings(meta.tickers), |
| 414 | unknownTickers: strings(meta.unknown_tickers), |
| 415 | includeUnlisted: meta.include_unlisted === true, |
| 416 | ready: meta.qualitative_ready === true, |
| 417 | max: number(meta.qualitative_max), |
| 418 | } |
| 419 | } |
| 420 | |
| 421 | function readRemoved(company: Record<string, unknown>): Removed { |
| 422 | return { |
| 423 | ticker: typeof company.ticker === 'string' ? company.ticker : null, |
| 424 | name: typeof company.name === 'string' ? company.name : null, |
| 425 | value: company.value ?? null, |
| 426 | } |
| 427 | } |
| 428 | |
| 429 | export function echoOf(screen: Screen): Echo | null { |
| 430 | if (!screen.sessionId) return null |
| 431 | return { |
| 432 | sessionId: screen.sessionId, |
| 433 | // The filter, and nothing the answer said about it. |
| 434 | filters: screen.applied.map(({ field, op, value }) => (value === undefined ? { field, op } : { field, op, value })), |
| 435 | tickers: screen.tickers, |
| 436 | includeUnlisted: screen.includeUnlisted, |
| 437 | } |
| 438 | } |
| 439 | |
| 440 | type Listed = Pick<State, 'filters' | 'tickers' | 'includeUnlisted'> |
| 441 | |
| 442 | /** Whether the server's session holds the list this state asks for. A run freezes the session's list, so it waits for this. */ |
| 443 | export function inSync(state: Listed & { sessionId: string | null }, echo: Echo | null): boolean { |
| 444 | return ( |
| 445 | echo !== null && |
| 446 | echo.sessionId === state.sessionId && |
| 447 | same(echo.filters.map(parts), state.filters.map(parts)) && |
| 448 | same(echo.tickers, readTickers(state.tickers.join(' '))) && |
| 449 | echo.includeUnlisted === state.includeUnlisted |
| 450 | ) |
| 451 | } |
| 452 | |
| 453 | /** How many of the companies each filter removes the server is asked to name: what one row of the funnel has room for. */ |
| 454 | export const REMOVED_NAMES = 8 |
| 455 | |
| 456 | /** |
| 457 | * What one screen_companies call sends. The list (filters, include_unlisted) goes whole when the |
| 458 | * session does not hold it yet, and is left out when only the sort, the page or the columns moved: |
| 459 | * to the server a call that names the list is a change of it, and one that does not is a read. |
| 460 | * No tickers leave the view unless the user has a list of their own: they go as the server's |
| 461 | * tickers parameter, an empty one only to clear a list the session still holds, and never as a column. |
| 462 | * Every call asks whom each filter removed, since the funnel is drawn from whichever answer is on screen. |
| 463 | */ |
| 464 | export function screenArgs( |
| 465 | state: Listed & Pick<State, 'sort' | 'page' | 'columns' | 'sessionId'>, |
| 466 | echo: Echo | null, |
| 467 | pageSize: number, |
| 468 | ): Record<string, unknown> { |
| 469 | const columns = state.columns.filter((field) => !IDENTITY.has(field)) |
| 470 | const read = { sort: state.sort, columns, limit: pageSize, offset: state.page * pageSize, removed_names: REMOVED_NAMES } |
| 471 | if (inSync(state, echo)) return { session_id: state.sessionId, ...read } |
| 472 | // Left out, the session's list stays, and a new session has none: [] is only how a list is taken back. |
| 473 | const held = (echo?.tickers.length ?? 0) > 0 |
| 474 | return { |
| 475 | ...(state.sessionId ? { session_id: state.sessionId } : {}), |
| 476 | filters: state.filters, |
| 477 | ...(state.tickers.length > 0 ? { tickers: state.tickers } : held ? { tickers: [] } : {}), |
| 478 | include_unlisted: state.includeUnlisted, |
| 479 | ...read, |
| 480 | } |
| 481 | } |
| 482 | |
| 483 | // The funnel: the universe, then what each filter did to the list in its turn. |
| 484 | |
| 485 | /** What the answer on screen says of one row of the funnel. */ |
| 486 | export interface FunnelAnswer { |
| 487 | /** Companies left after the row; null where the server does not say. */ |
| 488 | left: number | null |
| 489 | /** How many of those the row before it left the row removed. */ |
| 490 | out: number | null |
| 491 | /** Companies of the universe that pass the row's filter on its own; null for the universe. */ |
| 492 | matches: number | null |
| 493 | /** The first of the companies removed, in the table's order; null where the server does not name them. */ |
| 494 | removed: Removed[] | null |
| 495 | } |
| 496 | |
| 497 | export interface FunnelRow { |
| 498 | /** What the row is of: the universe and the filters up to it. Two rows with one key say the same, whichever answer they are read from. */ |
| 499 | key: string |
| 500 | /** Null for the universe. */ |
| 501 | filter: Filter | null |
| 502 | /** Null while the answer on screen is of another list. */ |
| 503 | answer: FunnelAnswer | null |
| 504 | } |
| 505 | |
| 506 | /** |
| 507 | * The funnel's rows, which are the state's: the universe, then one for each filter, so they are there |
| 508 | * before any answer is. A row has an answer when the one on screen is of the same universe and the |
| 509 | * same filters up to it; what a filter left depends on nothing after it, so a filter added at the end |
| 510 | * leaves every row above it as it was. |
| 511 | */ |
| 512 | export function funnelRows(asked: Listed, screen: Screen | null): FunnelRow[] { |
| 513 | const keys = funnelKeys(asked.tickers, asked.includeUnlisted, asked.filters) |
| 514 | const held = screen ? funnelKeys(screen.tickers, screen.includeUnlisted, screen.applied) : [] |
| 515 | let before: number | null = null |
| 516 | return keys.map((key, at) => { |
| 517 | const filter = asked.filters[at - 1] ?? null |
| 518 | if (!screen || held[at] !== key) return { key, filter, answer: null } |
| 519 | if (at === 0) { |
| 520 | before = screen.universe |
| 521 | return { key, filter, answer: { left: screen.universe, out: null, matches: null, removed: null } } |
| 522 | } |
| 523 | const applied = screen.applied[at - 1]! |
| 524 | // A server that does not say what a filter left still says it of two: the first leaves what it matches on its own, the last what passes them all. |
| 525 | const left = applied.left ?? (at === 1 ? applied.matches : at === screen.applied.length ? screen.count : null) |
| 526 | const out = before !== null && left !== null ? before - left : null |
| 527 | before = left |
| 528 | return { key, filter, answer: { left, out, matches: applied.matches, removed: applied.removed } } |
| 529 | }) |
| 530 | } |
| 531 | |
| 532 | /** One key for the universe and one for each filter, each of everything up to it. */ |
| 533 | function funnelKeys(tickers: string[], includeUnlisted: boolean, filters: Filter[]): string[] { |
| 534 | const universe = [readTickers(tickers.join(' ')), includeUnlisted] |
| 535 | return [null, ...filters].map((_, at) => JSON.stringify([universe, filters.slice(0, at).map(parts)])) |
| 536 | } |
| 537 | |
| 538 | export type RowState = 'done' | 'working' | 'pending' |
| 539 | |
| 540 | /** |
| 541 | * How each row of the funnel is drawn. `answered` rows have an answer and `shown` of them have been |
| 542 | * shown, since the rows of an answer appear one after another. One row is at work at a time: the next |
| 543 | * to be shown, or the first one a call is out for (`busy`); when nothing is on its way, none is. |
| 544 | */ |
| 545 | export function funnelStates(rows: number, answered: number, shown: number, busy: boolean): RowState[] { |
| 546 | const done = Math.min(answered, shown) |
| 547 | const moving = busy || shown < answered |
| 548 | return Array.from({ length: rows }, (_, at) => (at < done ? 'done' : at === done && moving ? 'working' : 'pending')) |
| 549 | } |
| 550 | |
| 551 | /** How far two lists agree from the start. */ |
| 552 | export function commonPrefix(a: readonly string[], b: readonly string[]): number { |
| 553 | let n = 0 |
| 554 | while (n < a.length && n < b.length && a[n] === b[n]) n++ |
| 555 | return n |
| 556 | } |
| 557 | |
| 558 | /** |
| 559 | * A company a filter removed, as a row of the funnel names it: its ticker, and the value that did not |
| 560 | * pass where that is a figure. A dash is a company with no value, which no comparison matches. A |
| 561 | * sentence of text or a list says too much for a line of names. |
| 562 | */ |
| 563 | export function removedText(company: Removed, field: Pick<Field, 'kind' | 'unit'> | undefined): string { |
| 564 | const who = company.ticker ?? company.name ?? '—' |
| 565 | return field?.kind === 'number' || field?.kind === 'date' ? `${who} ${cellText(company.value, field)}` : who |
| 566 | } |
| 567 | |
| 568 | /** How far over the qualitative stage's limit the list is, in the words the view and the orchestrator are told. */ |
| 569 | export function overLimit(screen: Pick<Screen, 'count' | 'max'>): string { |
| 570 | if (screen.count === 0) return 'The list is empty: there is nothing to check.' |
| 571 | const over = screen.count - screen.max |
| 572 | const n = (value: number): string => value.toLocaleString('en-US') |
| 573 | return `The list has ${n(screen.count)} companies, ${n(over)} over the ${screen.max} the qualitative stage takes. Narrow it first.` |
| 574 | } |
| 575 | |
| 576 | // The qualitative stage. |
| 577 | |
| 578 | /** A criterion's id from its question: the words that say something, as the server's pattern wants them (^[a-z0-9_]{1,40}$), unlike any taken. */ |
| 579 | export function slug(question: string, taken: string[] = []): string { |
| 580 | const words = question.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim().split(' ').filter(Boolean) |
| 581 | // A letter on its own is what an apostrophe left behind. |
| 582 | const telling = words.filter((word) => !SMALL_WORDS.has(word) && (word.length > 1 || /\d/.test(word))) |
| 583 | // Whole words, up to five, while they fit with room left for a number behind them. |
| 584 | let base = '' |
| 585 | for (const word of (telling.length > 0 ? telling : words).slice(0, 5)) { |
| 586 | if (base && base.length + 1 + word.length > 36) break |
| 587 | base = base ? `${base}_${word}` : word.slice(0, 36) |
| 588 | } |
| 589 | base ||= 'criterion' |
| 590 | let id = base |
| 591 | for (let n = 2; taken.includes(id); n++) id = `${base}_${n}` |
| 592 | return id |
| 593 | } |
| 594 | |
| 595 | const SMALL_WORDS = new Set( |
| 596 | 'a an and any are as at be been by do does for from has have in is it its of on or that the their this to was were which with company'.split(' '), |
| 597 | ) |
| 598 | |
| 599 | /** |
| 600 | * Whether a run still has to be started. A `run` with no id is the request, from the Run button or |
| 601 | * from the orchestrator; what a model fills in beside it (a status, an empty id) does not count as |
| 602 | * started. Only what the view itself leaves does: the server's run id, or a failed start with its reason. |
| 603 | */ |
| 604 | export function needsStart(criteria: Criterion[] | undefined, run: Run | null | undefined): boolean { |
| 605 | return Boolean(criteria?.length) && run !== null && run !== undefined && !run.id && !(run.status === 'error' && run.error) |
| 606 | } |
| 607 | |
| 608 | /** |
| 609 | * Where the qualitative stage stands, in one word, for the bar and the orchestrator alike. A run asked |
| 610 | * for with no criteria never starts, and must not read as if it had: that is how a reply came to promise |
| 611 | * results of a run that did not exist. What a model fills in beside the request counts no more here than in needsStart. |
| 612 | */ |
| 613 | export type Stage = 'none' | 'no_criteria' | 'starting' | 'running' | 'done' | 'error' |
| 614 | |
| 615 | export function stageOf(criteria: Criterion[] | undefined, run: Run | null | undefined): Stage { |
| 616 | if (!run) return 'none' |
| 617 | if (run.status === 'error' && run.error) return 'error' |
| 618 | if (!run.id) return criteria?.length ? 'starting' : 'no_criteria' |
| 619 | return run.status === 'done' ? 'done' : 'running' |
| 620 | } |
| 621 | |
| 622 | /** What a started run answers with. */ |
| 623 | export function readStart(text: string): { runId: string; total: number } { |
| 624 | const value = parseJson(text) |
| 625 | if (typeof value.run_id !== 'string' || !value.run_id) throw new Error('the run did not start: no run id came back') |
| 626 | return { runId: value.run_id, total: number(value.total) } |
| 627 | } |
| 628 | |
| 629 | export interface Progress { |
| 630 | total: number |
| 631 | queued: number |
| 632 | running: number |
| 633 | error: number |
| 634 | pass: number |
| 635 | fail: number |
| 636 | unclear: number |
| 637 | unverified: number |
| 638 | } |
| 639 | |
| 640 | /** One quote of a filing that an answer rests on, already checked by the server. */ |
| 641 | export interface Citation { |
| 642 | id: string |
| 643 | title: string |
| 644 | url: string | null |
| 645 | quote: string |
| 646 | } |
| 647 | |
| 648 | export interface CriterionResult { |
| 649 | id: string |
| 650 | verdict: string |
| 651 | rationale: string |
| 652 | verification: string |
| 653 | confidence: number | null |
| 654 | note: string |
| 655 | /** Citation ids. */ |
| 656 | evidence: string[] |
| 657 | gathered: { searched: number; judged: number; paragraphs: number; relevant: number } | null |
| 658 | } |
| 659 | |
| 660 | export interface CompanyResult { |
| 661 | cik: number |
| 662 | ticker: string | null |
| 663 | name: string | null |
| 664 | status: string |
| 665 | /** While it is being read: the step it is in, as the server names it. */ |
| 666 | step: string | null |
| 667 | verdict: Verdict | null |
| 668 | error: string | null |
| 669 | criteria: CriterionResult[] |
| 670 | } |
| 671 | |
| 672 | /** A run's first phase: its companies' earnings calls are fetched, and no company is read before they are in. Off: the run reads the filings alone. */ |
| 673 | export interface Phase { |
| 674 | status: (typeof PHASES)[number] |
| 675 | /** Companies looked at, of how many. */ |
| 676 | done: number |
| 677 | total: number |
| 678 | } |
| 679 | |
| 680 | const PHASES = ['fetching', 'done', 'off'] as const |
| 681 | |
| 682 | export interface RunSnapshot { |
| 683 | status: 'queued' | 'running' | 'done' |
| 684 | /** When the run started and ended, by the server's clock, as ISO text. */ |
| 685 | createdAt: string | null |
| 686 | finishedAt: string | null |
| 687 | progress: Progress |
| 688 | /** Null before the phase starts, and from a server that does not say. */ |
| 689 | phase: Phase | null |
| 690 | criteria: Criterion[] |
| 691 | results: CompanyResult[] |
| 692 | citations: Record<string, Citation> |
| 693 | } |
| 694 | |
| 695 | /** |
| 696 | * One get_qualitative_screen answer: its results are the dataset's rows, the rest its meta. Read |
| 697 | * leniently. A criterion's evidence is a list of citation ids; the citations themselves sit once at the top. |
| 698 | */ |
| 699 | export function readRun(meta: Record<string, unknown>, rows: Record<string, unknown>[]): RunSnapshot { |
| 700 | const citations: Record<string, Citation> = {} |
| 701 | for (const entry of Array.isArray(meta.citations) ? meta.citations : []) { |
| 702 | const citation = readCitation(entry) |
| 703 | if (citation) citations[citation.id] = citation |
| 704 | } |
| 705 | const results = rows.map((row): CompanyResult => ({ |
| 706 | cik: number(row.cik), |
| 707 | ticker: typeof row.ticker === 'string' ? row.ticker : null, |
| 708 | name: typeof row.name === 'string' ? row.name : null, |
| 709 | status: typeof row.status === 'string' ? row.status : 'queued', |
| 710 | step: typeof row.step === 'string' ? row.step : null, |
| 711 | verdict: VERDICTS.includes(row.verdict as Verdict) ? (row.verdict as Verdict) : null, |
| 712 | error: typeof row.error === 'string' ? row.error : null, |
| 713 | criteria: (Array.isArray(row.criteria) ? row.criteria.filter(isRecord) : []).map((c) => readCriterion(c, citations)), |
| 714 | })) |
| 715 | const progress = isRecord(meta.progress) ? meta.progress : {} |
| 716 | return { |
| 717 | status: meta.status === 'done' ? 'done' : meta.status === 'queued' ? 'queued' : 'running', |
| 718 | createdAt: typeof meta.created_at === 'string' ? meta.created_at : null, |
| 719 | finishedAt: typeof meta.finished_at === 'string' ? meta.finished_at : null, |
| 720 | phase: readPhase(meta.earnings_calls), |
| 721 | progress: { |
| 722 | total: number(progress.total), |
| 723 | queued: number(progress.queued), |
| 724 | running: number(progress.running), |
| 725 | error: number(progress.error), |
| 726 | pass: number(progress.pass), |
| 727 | fail: number(progress.fail), |
| 728 | unclear: number(progress.unclear), |
| 729 | unverified: number(progress.unverified), |
| 730 | }, |
| 731 | criteria: (Array.isArray(meta.criteria) ? meta.criteria.filter(isRecord) : []) |
| 732 | .filter((c) => typeof c.id === 'string' && typeof c.question === 'string') |
| 733 | .map((c) => ({ id: c.id as string, question: c.question as string })), |
| 734 | results, |
| 735 | citations, |
| 736 | } |
| 737 | } |
| 738 | |
| 739 | /** The same answer when it came back as text rather than rows. */ |
| 740 | export function readRunText(text: string): RunSnapshot { |
| 741 | const { results, ...meta } = parseJson(text) |
| 742 | return readRun(meta, Array.isArray(results) ? results.filter(isRecord) : []) |
| 743 | } |
| 744 | |
| 745 | function readPhase(value: unknown): Phase | null { |
| 746 | if (!isRecord(value)) return null |
| 747 | const status = PHASES.find((one) => one === value.status) |
| 748 | return status ? { status, done: number(value.done), total: number(value.total) } : null |
| 749 | } |
| 750 | |
| 751 | function readCitation(entry: unknown): Citation | null { |
| 752 | if (!isRecord(entry) || typeof entry.id !== 'string' || !entry.id) return null |
| 753 | const url = typeof entry.url === 'string' && entry.url.startsWith('https://') ? entry.url : null |
| 754 | return { |
| 755 | id: entry.id, |
| 756 | title: typeof entry.title === 'string' ? entry.title : '', |
| 757 | url, |
| 758 | quote: typeof entry.quote === 'string' ? entry.quote : '', |
| 759 | } |
| 760 | } |
| 761 | |
| 762 | function readCriterion(c: Record<string, unknown>, citations: Record<string, Citation>): CriterionResult { |
| 763 | // An id with no citation behind it has nothing to show, so it is not kept. |
| 764 | const evidence = (Array.isArray(c.evidence) ? c.evidence : []).filter((id): id is string => typeof id === 'string' && id in citations) |
| 765 | const gathered = isRecord(c.gathered) ? c.gathered : null |
| 766 | return { |
| 767 | id: typeof c.id === 'string' ? c.id : '', |
| 768 | verdict: typeof c.verdict === 'string' ? c.verdict : '', |
| 769 | rationale: typeof c.rationale === 'string' ? c.rationale : '', |
| 770 | verification: typeof c.verification === 'string' ? c.verification : '', |
| 771 | confidence: typeof c.confidence === 'number' ? c.confidence : null, |
| 772 | note: typeof c.note === 'string' ? c.note : '', |
| 773 | evidence, |
| 774 | gathered: gathered && { |
| 775 | searched: Array.isArray(gathered.searched) ? gathered.searched.length : 0, |
| 776 | judged: number(gathered.judged), |
| 777 | paragraphs: number(gathered.paragraphs), |
| 778 | relevant: Array.isArray(gathered.good) ? gathered.good.length : 0, |
| 779 | }, |
| 780 | } |
| 781 | } |
| 782 | |
| 783 | /** The companies with one verdict, by ticker. */ |
| 784 | export function verdictTickers(run: Pick<RunSnapshot, 'results'> | null, verdict: Verdict): string[] { |
| 785 | return (run?.results ?? []).flatMap((result) => (result.verdict === verdict && result.ticker ? [result.ticker] : [])) |
| 786 | } |
| 787 | |
| 788 | /** The companies that passed, by ticker. The table's rows are the list that was read; only these are what the user asked for. */ |
| 789 | export function passedTickers(run: Pick<RunSnapshot, 'results'> | null): string[] { |
| 790 | return verdictTickers(run, 'pass') |
| 791 | } |
| 792 | |
| 793 | /** A company's part of a run is over, as done or as an error. */ |
| 794 | function over(result: Pick<CompanyResult, 'status'>): boolean { |
| 795 | return result.status === 'done' || result.status === 'error' |
| 796 | } |
| 797 | |
| 798 | /** |
| 799 | * What one read of a run asks for. The first is the whole run, at once, so a panel that comes back to |
| 800 | * one shows where it is. Each one after waits on the server a few seconds and asks only for the |
| 801 | * companies not yet over: a company's answers come once, when it finishes, and the app, which keeps |
| 802 | * every answer it is given, is not handed the whole run again every few seconds. While the earnings |
| 803 | * calls are fetched no company is read, so none is asked for and the answer is the counts alone. |
| 804 | */ |
| 805 | export function followArgs(runId: string, seen: RunSnapshot | null, waitSeconds: number): Record<string, unknown> { |
| 806 | if (!seen) return { run_id: runId } |
| 807 | const reading = seen.phase?.status === 'fetching' ? [] : seen.results.flatMap((result) => (!over(result) && result.ticker ? [result.ticker] : [])) |
| 808 | return { run_id: runId, wait_seconds: waitSeconds, tickers: reading } |
| 809 | } |
| 810 | |
| 811 | /** A run as it was last seen, brought up to date by an answer that holds only some of its companies. */ |
| 812 | export function mergeRun(seen: RunSnapshot, next: RunSnapshot): RunSnapshot { |
| 813 | const fresh = new Map(next.results.map((result) => [result.cik, result])) |
| 814 | return { ...next, results: seen.results.map((result) => fresh.get(result.cik) ?? result) } |
| 815 | } |
| 816 | |
| 817 | /** How many of a run's companies are over, as done or as an error. */ |
| 818 | export function finished(progress: Progress): number { |
| 819 | return progress.total - progress.queued - progress.running |
| 820 | } |
| 821 | |
| 822 | /** What a run is doing, in a few words. Its companies' earnings calls come first: no company is read before they are in. */ |
| 823 | export function doingText(run: Pick<RunSnapshot, 'status' | 'progress' | 'phase'>): string { |
| 824 | const { progress, phase } = run |
| 825 | if (run.status === 'done') return `${progress.total} read` |
| 826 | if (phase?.status === 'fetching') return `fetching earnings calls: ${phase.done} of ${phase.total}` |
| 827 | return `reading: ${finished(progress)} of ${progress.total}` |
| 828 | } |
| 829 | |
| 830 | /** Where a run is, in one line: what it is doing, then the counts by verdict. */ |
| 831 | export function progressText(run: Pick<RunSnapshot, 'status' | 'progress' | 'phase'>): string { |
| 832 | const counts = VERDICTS.map((verdict) => `${run.progress[verdict]} ${verdict}`) |
| 833 | if (run.progress.error > 0) counts.push(`${run.progress.error} error`) |
| 834 | return `${doingText(run)} · ${counts.join(' · ')}` |
| 835 | } |
| 836 | |
| 837 | /** |
| 838 | * How long a run has gone, or took, as a clock: 1:42, and 1:02:03 past the hour. It counts from when |
| 839 | * the server says the run started, so a panel that comes back to a run shows its time and not its |
| 840 | * own; a clock here that is behind the server's never counts backwards. Null for a run that does not say. |
| 841 | */ |
| 842 | export function elapsedText(run: Pick<RunSnapshot, 'createdAt' | 'finishedAt'>, now: number): string | null { |
| 843 | const from = run.createdAt ? Date.parse(run.createdAt) : NaN |
| 844 | const to = run.finishedAt ? Date.parse(run.finishedAt) : now |
| 845 | if (!Number.isFinite(from) || !Number.isFinite(to)) return null |
| 846 | const seconds = Math.max(0, Math.floor((to - from) / 1000)) |
| 847 | const two = (value: number): string => String(value).padStart(2, '0') |
| 848 | const minutes = Math.floor((seconds % 3600) / 60) |
| 849 | return seconds >= 3600 ? `${Math.floor(seconds / 3600)}:${two(minutes)}:${two(seconds % 60)}` : `${minutes}:${two(seconds % 60)}` |
| 850 | } |
| 851 | |
| 852 | /** The steps the server takes a company through while it reads it, in order, as it names them, and the word for each. */ |
| 853 | const READING: [step: string, word: string][] = [ |
| 854 | ['search', 'search'], |
| 855 | ['jev', 'relevance'], |
| 856 | ['llm', 'answer'], |
| 857 | ['verify', 'verify'], |
| 858 | ] |
| 859 | |
| 860 | /** Where a company being read is: how many steps are behind it, of how many, and the word for the one it is in. Null on none yet, and on one this view has not heard of. */ |
| 861 | export function readingAt(step: string | null): { at: number; of: number; word: string } | null { |
| 862 | const at = READING.findIndex(([name]) => name === step) |
| 863 | return at < 0 ? null : { at, of: READING.length, word: READING[at]![1] } |
| 864 | } |
| 865 | |
| 866 | /** A verification that did not hold, in words; nothing for one that did, or for a claim of absence, which cites nothing. */ |
| 867 | export function verificationMark(verification: string): string | null { |
| 868 | if (!verification || verification === 'supports' || verification === 'not_checked') return null |
| 869 | if (verification === 'contradicts') return 'The cited evidence contradicts this verdict.' |
| 870 | if (verification === 'says_nothing') return 'The cited evidence does not show this.' |
| 871 | if (verification === 'uncertain') return 'The verifier was not confident the cited evidence carries this verdict.' |
| 872 | return `Verification: ${verification}.` |
| 873 | } |
| 874 | |
| 875 | /** What was searched and read for one criterion, in one line. */ |
| 876 | export function gatheredText(gathered: NonNullable<CriterionResult['gathered']>): string { |
| 877 | const n = (count: number, one: string, many: string): string => `${count.toLocaleString('en-US')} ${count === 1 ? one : many}` |
| 878 | return [ |
| 879 | `${n(gathered.searched, 'search', 'searches')} made`, |
| 880 | `${n(gathered.judged, 'passage', 'passages')} judged`, |
| 881 | `${n(gathered.paragraphs, 'paragraph', 'paragraphs')} read`, |
| 882 | `${gathered.relevant.toLocaleString('en-US')} relevant`, |
| 883 | ].join(', ') |
| 884 | } |
| 885 | |
| 886 | /** |
| 887 | * The open company's evidence as text a model can quote. The first line names what it is, since a |
| 888 | * citation of the panel takes that line as its title; each quote is followed by its marker, [^id], |
| 889 | * which the chat resolves against the citations of the same tool result. |
| 890 | */ |
| 891 | export function evidenceText(company: CompanyResult, questions: Criterion[], citations: Record<string, Citation>, runId: string): string { |
| 892 | const name = company.name ? ` (${company.name})` : '' |
| 893 | const lines = [`Screener evidence for ${company.ticker ?? `CIK ${company.cik}`}${name}, qualitative run ${runId}: ${company.verdict ?? company.status}`] |
| 894 | if (company.error) lines.push('', `Error: ${company.error}`) |
| 895 | for (const c of company.criteria) { |
| 896 | const question = questions.find((q) => q.id === c.id)?.question |
| 897 | const confidence = c.confidence === null ? '' : `, confidence ${c.confidence.toFixed(2)}` |
| 898 | lines.push('', `## ${c.id}${question ? `: ${question}` : ''}`, `Verdict: ${c.verdict} (verification: ${c.verification || 'none'}${confidence})`) |
| 899 | if (c.rationale) lines.push(`Rationale: ${c.rationale}`) |
| 900 | if (c.note) lines.push(`Note: ${c.note}`) |
| 901 | for (const id of c.evidence) { |
| 902 | const citation = citations[id] |
| 903 | if (citation) lines.push(`- "${citation.quote}" [^${citation.id}]${citation.title ? ` (${citation.title})` : ''}`) |
| 904 | } |
| 905 | if (c.gathered) lines.push(`Retrieval: ${gatheredText(c.gathered)}.`) |
| 906 | } |
| 907 | return lines.join('\n') |
| 908 | } |
| 909 | |
| 910 | // What the orchestrator reads. |
| 911 | |
| 912 | /** What the view publishes: the screen behind the rows on screen, and where the run is. */ |
| 913 | export interface Output { |
| 914 | session_id: string | null |
| 915 | universe: number |
| 916 | count: number |
| 917 | /** matches: what the filter passes on its own. left: what remains after it and every one before it, which is what the funnel shows. */ |
| 918 | applied_filters: (Filter & { matches: number; left?: number })[] |
| 919 | /** The tickers on this page, in order. */ |
| 920 | tickers: string[] |
| 921 | unknown_tickers: string[] |
| 922 | sort: Sort |
| 923 | qualitative_ready: boolean |
| 924 | qualitative_max: number |
| 925 | /** status is a Stage. earnings_calls: the run's first phase, once it has started. passed: the tickers with a pass, once there are results; the rows above are only the list that was read. */ |
| 926 | qualitative?: { run_id: string | null; status: string; progress: Progress | null; earnings_calls?: Phase; passed?: string[]; error?: string } |
| 927 | open: string |
| 928 | } |
| 929 | |
| 930 | /** The filters as the output carries them: what each left, as the funnel shows it, and not whom it removed, which is a company's worth of names for every filter. */ |
| 931 | export function publishedFilters(screen: Pick<Screen, 'applied'> | null): Output['applied_filters'] { |
| 932 | return (screen?.applied ?? []).map(({ removed: _removed, left, ...filter }) => (left === null ? filter : { ...filter, left })) |
| 933 | } |
| 934 | |
| 935 | /** The app takes 4,096 bytes of output: the tickers at the end of the page give way first. */ |
| 936 | export const OUTPUT_BYTES = 3800 |
| 937 | |
| 938 | export function fitOutput(output: Output): Output { |
| 939 | let fitted = output |
| 940 | while (fitted.tickers.length > 0 && byteLength(JSON.stringify(fitted)) > OUTPUT_BYTES) { |
| 941 | fitted = { ...fitted, tickers: fitted.tickers.slice(0, Math.floor(fitted.tickers.length * 0.8)) } |
| 942 | } |
| 943 | return fitted |
| 944 | } |
| 945 | |
| 946 | /** The one line the model's map carries: how many pass, of how many, behind which screen, and where the run is. */ |
| 947 | export function summarize(state: Partial<State>, output: Partial<Output>): string { |
| 948 | const n = (value: number | undefined): string => (value ?? 0).toLocaleString('en-US') |
| 949 | const filters = state.filters ?? [] |
| 950 | const parts = [`Screener: ${n(output.count)} of ${n(output.universe)} pass`] |
| 951 | if (filters.length > 0) parts[0] += ` ${filters.length} filter${filters.length === 1 ? '' : 's'}` |
| 952 | if (state.tickers?.length) parts.push(`own list of ${state.tickers.length}`) |
| 953 | const sort = state.sort ?? DEFAULT_SORT |
| 954 | parts.push(`sort ${sort.field} ${sort.dir}`) |
| 955 | const q = output.qualitative |
| 956 | if (q) { |
| 957 | const progress = q.progress |
| 958 | parts.push( |
| 959 | q.status === 'error' |
| 960 | ? `qualitative run failed: ${q.error ?? 'unknown error'}` |
| 961 | : progress |
| 962 | ? `qualitative run ${q.status}: ${progressText({ status: q.status === 'done' ? 'done' : 'running', progress, phase: q.earnings_calls ?? null })}` |
| 963 | : q.status === 'no_criteria' |
| 964 | ? 'a qualitative run was asked for but NOT started: state.criteria is empty' |
| 965 | : `qualitative run ${q.status}`, |
| 966 | ) |
| 967 | } else if (output.qualitative_ready) parts.push('ready for the qualitative stage') |
| 968 | if (output.open) parts.push(`evidence open for ${output.open}`) |
| 969 | return parts.join(', ') |
| 970 | } |
| 971 | |
| 972 | // Small things. |
| 973 | |
| 974 | function parseJson(text: string): Record<string, unknown> { |
| 975 | try { |
| 976 | const value: unknown = JSON.parse(text) |
| 977 | return isRecord(value) ? value : {} |
| 978 | } catch { |
| 979 | return {} |
| 980 | } |
| 981 | } |
| 982 | |
| 983 | function strings(value: unknown): string[] { |
| 984 | return Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : [] |
| 985 | } |
| 986 | |
| 987 | function number(value: unknown): number { |
| 988 | return typeof value === 'number' && Number.isFinite(value) ? value : 0 |
| 989 | } |
| 990 | |
| 991 | function isRecord(value: unknown): value is Record<string, unknown> { |
| 992 | return typeof value === 'object' && value !== null && !Array.isArray(value) |
| 993 | } |
| 994 | |
| 995 | /** A filter by what it says, whatever order its keys were written in. */ |
| 996 | function parts(filter: Filter): unknown[] { |
| 997 | return [filter.field, filter.op, filter.value ?? null] |
| 998 | } |
| 999 | |
| 1000 | function same(a: unknown, b: unknown): boolean { |
| 1001 | return JSON.stringify(a) === JSON.stringify(b) |
| 1002 | } |
| 1003 | |
| 1004 | /** A product or a quotient without the digits floating point adds at the end: 15.3 / 100 is 0.153. */ |
| 1005 | function clean(value: number): number { |
| 1006 | return Number(value.toPrecision(12)) |
| 1007 | } |
| 1008 | |
| 1009 | function byteLength(text: string): number { |
| 1010 | return new TextEncoder().encode(text).length |
| 1011 | } |