/** * The #2051 learning loop: scheduled aggregation/retention over * `_smrt_field_usage_counters` and threshold-driven generation of * `_smrt_field_policy_suggestions`. * * Scheduling substrate: there is NO generic cron package API — the de-facto * convention is an `AgentSchedule` row (`@happyvertical/smrt-agents`) * dispatched by smrt-jobs' `ScheduleRunner`, which resolves `agentType` * through the `ObjectRegistry` and invokes the method on the registered * class. {@link FieldUsageLearningAgent} is that schedule target. It is a * registered `@smrt()` SmrtObject — deliberately NOT a subclass of the agents * package's `Agent` base: smrt-agents sits above smrt-fields in the * dependency DAG (it hard-depends on smrt-users/ai/secrets, all of which this * package keeps optional), and the dispatch machinery only requires registry * resolution plus the jobs package's `backgroundEligibleMethods` allowlist * contract (a static property, no import needed). Schedule rows are created * DORMANT by `ensureFieldUsageLearningSchedules` (see `usage-schedules.ts`). * * Both jobs are tenant-safe by construction: in trusted execution (no ambient * tenant context, or super-admin bypass) they operate across all tenants; * inside a non-bypass tenant context they restrict themselves to the ambient * tenant (fail closed), so per-tenant schedule rows are also valid. */ import { SmrtObject, type SmrtObjectOptions } from '@happyvertical/smrt-core'; import type { DatabaseInterface } from '@happyvertical/sql'; import type { FieldPolicySuggestionKind } from './types.js'; export interface FieldUsageMaintenanceConfig { /** Drop counter buckets older than this (by `period`). Default 90 days. */ counterMaxAgeMs: number; /** Keep at most this many counter rows (oldest pruned first). Default 100k. */ counterMaxRows: number; /** Drop ACCEPTED suggestions decided longer ago than this. Default 180 days. */ suggestionAcceptedMaxAgeMs: number; } export declare const FIELD_USAGE_MAINTENANCE_DEFAULTS: FieldUsageMaintenanceConfig; export interface FieldUsageSuggestionConfig { /** Usage window the thresholds evaluate over. Default 30 days. */ windowDays: number; /** Distinct users required for a `promote` suggestion. Default 5. */ minDistinctUsers: number; /** Submissions required before a `default` suggestion. Default 10. */ minSetCount: number; /** * Share of windowed submissions a single value must reach for a `default` * suggestion (denominator is TOTAL setCount, so histogram overflow can only * make this more conservative). Default 0.8. */ defaultDominanceRatio: number; } export declare const FIELD_USAGE_SUGGESTION_DEFAULTS: FieldUsageSuggestionConfig; export interface FieldUsageMaintenanceSummary { countersPruned: number; receiptsPruned: number; suggestionsPruned: number; } export interface FieldUsageSuggestionRunSummary { /** * Suggestions this run wrote. Under overlapping runs both may report a * create for the same candidate while the model's active-slot unique index * converges them onto ONE row (see `FieldPolicySuggestion.activeKey`) — the * tally is per-run work, not a row count. */ created: number; /** Candidates suppressed by a pending or cooling-down suggestion. */ suppressed: number; /** Distinct (tenant, objectRef, fieldName) groups evaluated. */ groupsConsidered: number; /** * Groups whose evaluation threw and was skipped. A failing group never * aborts the run — a global pass must keep serving every other tenant. */ groupsFailed: number; /** * Bounded sample of failure messages (`MAX_REPORTED_GROUP_FAILURES`) so an * operator can see WHY without the summary growing with the queue. */ failures: string[]; } /** Cap on {@link FieldUsageSuggestionRunSummary.failures} entries. */ export declare const MAX_REPORTED_GROUP_FAILURES = 5; export interface FieldUsageCounterRetention { maxAgeMs?: number; maxRows?: number; /** Restrict pruning to one tenant (ambient-context runs). */ tenantId?: string | null; } /** * Prune counter rows to bound growth. Applies whichever bounds are provided * (at least one required): `maxAgeMs` drops buckets whose `period` day is * older than the cutoff; `maxRows` keeps only the newest N rows by * `(period, id)`, deleting oldest-first. Mirrors core's `pruneChangeFeed`. */ export declare function pruneFieldUsageCounters(db: DatabaseInterface, retention: FieldUsageCounterRetention): Promise<{ pruned: number; }>; /** * Drop durable anti-inflation receipts at the same age cutoff as counters. * * Deliberately do NOT delete a receipt merely because its counter row is * absent: report ingestion claims its receipt before merging the counter, so * an orphan sweep could otherwise reopen the once-per-user/day quota during * that in-flight interval. A max-row counter trim likewise leaves its recent * receipts until the age cutoff — retaining a de-duplication guard is safer * than allowing a second contribution for that day. */ export declare function pruneFieldUsageReportReceipts(db: DatabaseInterface, options: { maxAgeMs: number; tenantId?: string | null; }): Promise<{ pruned: number; }>; export interface FieldPolicySuggestionRetention { /** Drop ACCEPTED suggestions decided longer ago than this. */ acceptedMaxAgeMs: number; /** Restrict pruning to one tenant (ambient-context runs). */ tenantId?: string | null; /** Clock override for deterministic tests. */ now?: Date; } /** * Prune settled suggestion rows ONLY (#2051 pin): a dismissed suggestion once * its cool-down has fully elapsed (its suppression job is done), and an * accepted suggestion once it is old. Pending suggestions are NEVER pruned. */ export declare function pruneFieldPolicySuggestions(db: DatabaseInterface, retention: FieldPolicySuggestionRetention): Promise<{ pruned: number; }>; export interface RunFieldUsageMaintenanceOptions extends Partial { db: DatabaseInterface; } /** * The "aggregation" schedule's work. Ingestion pre-aggregates into period * buckets, so the roll-up job's real job is retention: prune counter buckets * and durable receipts at the shared age cutoff, then settle old suggestion * rows. Max-row trimming leaves recent receipts intact to preserve daily * de-duplication while an ingestion merge is in flight. */ export declare function runFieldUsageMaintenance(options: RunFieldUsageMaintenanceOptions): Promise; export interface RunFieldPolicySuggestionGenerationOptions extends Partial { db: DatabaseInterface; /** Clock override for deterministic tests. */ now?: Date; } /** * The threshold job: evaluate windowed counters per * `(tenantId, objectRef, fieldName)` and create PENDING suggestions with * human-readable evidence. * * - `promote`: at least `minDistinctUsers` distinct users set the field to a * non-default value in the window AND the org-resolved visibility is not * already `basic`. * - `default`: at least `minSetCount` TOTAL submissions, a single recorded * value covers `defaultDominanceRatio` of those TOTAL submissions (not of * the deviations — see `FieldUsageCounter.submissionCount`), and it differs * from the org-resolved default. Only histogram-eligible fields * (low-cardinality, non-sensitive, non-gated) can ever qualify, and a group * containing a legacy bucket with no recorded total is skipped rather than * ratioed against the wrong denominator. * * Dedup: a candidate is suppressed while the same * `(tenantId, objectRef, fieldName, kind)` has a PENDING suggestion or a * DISMISSED one still inside its cool-down. Accepted history never blocks — * once accepted, the resolved policy itself stops regeneration (visibility is * basic / the default matches). The pre-check is an optimization only: the * single-active guarantee is STRUCTURAL (`FieldPolicySuggestion.activeKey` in * `conflictColumns`), so overlapping runs upsert onto one row instead of * duplicating. * * Sensitive, read-permission-gated, and transient fields are skipped * entirely: their usage rows are count-only observability data and never * produce suggestions. */ export declare function runFieldPolicySuggestionGeneration(options: RunFieldPolicySuggestionGenerationOptions): Promise; /** * The registered schedule target for the #2051 learning loop (see the module * doc for why it is NOT an smrt-agents `Agent` subclass). Rows of its system * table are never written — the class exists so `AgentSchedule.agentType` * resolves through the `ObjectRegistry` and smrt-jobs can construct it and * invoke the two allowlisted methods. */ export declare class FieldUsageLearningAgent extends SmrtObject { /** * The smrt-jobs opt-in background allowlist (S5 contract): ONLY these two * methods are reachable from a persisted job/schedule row. */ static backgroundEligibleMethods: ReadonlyArray; constructor(options?: SmrtObjectOptions); /** Schedule entry point for {@link runFieldUsageMaintenance}. */ runUsageMaintenance(args?: Record): Promise; /** Schedule entry point for {@link runFieldPolicySuggestionGeneration}. */ runSuggestionGeneration(args?: Record): Promise; } export interface FieldUsageEvidenceInput { kind: FieldPolicySuggestionKind; objectRef: string; fieldName: string; windowStart: string; windowEnd: string; distinctUsers: number; /** True when the distinct-user set overflowed (count is a lower bound). */ distinctUsersAtLeast: boolean; /** Submissions that DIFFERED from the resolved default. */ setCount: number; /** * TOTAL submissions observed in the window (the dominance denominator). * `undefined` only for legacy buckets that never recorded a total, in which * case the summary states the deviation count alone. */ submissionCount?: number; /** The threshold the candidate cleared (documented in the evidence). */ threshold: number; topValue?: unknown; topValueShare?: number; } /** * Human-readable evidence for a suggestion: a `summary` sentence a reviewer * can read as-is, plus the structured numbers behind it. * * The sentence always states BOTH numbers when known — deviations and total * submissions — so a reviewer can see the base rate a dominance percentage was * computed against instead of trusting a bare ratio. */ export declare function buildFieldUsageEvidence(input: FieldUsageEvidenceInput): Record; //# sourceMappingURL=usage-learning.d.ts.map