import { SmrtObject, type SmrtObjectOptions } from '@happyvertical/smrt-core'; /** * Cap on distinct-user ids stored per bucket. For threshold questions * ("did at least N distinct users set this field?") the capped set is EXACT up * to the cap; once overflowed, {@link FieldUsageCounter.distinctUserCount} is * an honest LOWER BOUND that trivially satisfies any threshold ≤ the cap. */ export declare const MAX_DISTINCT_USERS_PER_BUCKET = 100; /** Cap on histogram buckets per counter row (bounded storage). */ export declare const MAX_VALUE_HISTOGRAM_BUCKETS = 25; /** Longest histogram key recorded; longer samples are skipped (count-only). */ export declare const MAX_VALUE_HISTOGRAM_KEY_LENGTH = 64; /** `period` bucket format: UTC calendar day. */ export declare const FIELD_USAGE_PERIOD_PATTERN: RegExp; /** The UTC day bucket for a timestamp (`YYYY-MM-DD`). */ export declare function fieldUsagePeriodForDate(date: Date): string; /** * A prototype-free histogram map. * * Histogram keys are user-supplied values (an `idType: 'text'` reference id may * legitimately be `constructor`, `toString`, or `__proto__`). On a plain object * those either resolve to inherited members — making an absent bucket look * present and corrupting its count — or, for `__proto__`, invoke the prototype * setter instead of creating an own key. A null-prototype object has no such * members, so every key behaves like data. Use this everywhere histogram counts * are accumulated (storage AND merge paths). */ export declare function emptyHistogram(): Record; export interface FieldUsageCounterOptions extends SmrtObjectOptions { objectRef?: string; fieldName?: string; tenantId?: string; period?: string; submissionCount?: number; setCount?: number; distinctUserCount?: number; distinctUserIds?: string; distinctUsersOverflowed?: boolean; valueHistogram?: string | null; valueHistogramOverflowed?: boolean; } /** * Period-bucketed field usage counter (epic #2045, issue #2051). * * One row aggregates field submissions for a single * `(objectRef, fieldName, tenantId, period)` — the substrate the * suggestion-generation job reads. Counters are deliberately APPROXIMATE: * ingestion is fire-and-forget and concurrent bucket merges may lose an * increment (read-modify-write), which is acceptable for usage statistics and * documented here rather than papered over. * * TWO counters, because they answer different questions: * - {@link submissionCount} — EVERY observed submission of the field * (default-matching or not). It is the denominator for value dominance: * "N% of submissions used value V". Without it, a value seen only in * deviations would look 100% dominant even against thousands of * default-valued submissions. * - {@link setCount} — submissions whose value DIFFERED from the resolved * default (server-derived). It plus {@link distinctUserIds} is the * promote signal ("real users are actively filling this in"). * * Content rails (enforced by the ingestion action, which derives everything * from the live registry and never trusts the client): * - Sensitive and read-permission-gated fields are COUNT-ONLY: their raw * values are never recorded anywhere in usage data — not even for * default-matching submissions. * - Value histograms exist only for low-cardinality field types (`boolean`, * `foreignKey`, `crossPackageRef`) — never free text, even non-sensitive * text (PII risk) — with a bounded bucket count and key length. They cover * ALL submissions (not just deviations) so the dominance ratio is a true * fraction of {@link submissionCount}. * - `distinctUserIds` is a capped set with an overflow marker (see * {@link MAX_DISTINCT_USERS_PER_BUCKET} for the honesty contract). */ export declare class FieldUsageCounter extends SmrtObject { /** Qualified class name of the target object (`@package/name:ClassName`). */ objectRef: string; /** Field name on the target object. */ fieldName: string; /** * Owning tenant. REQUIRED: ingestion fails closed without an ambient tenant * context, so every row is attributable (and the conflict-column tuple * stays total). Native UUID on PostgreSQL/DuckDB. */ tenantId?: string; /** UTC day bucket (`YYYY-MM-DD`); lexicographic order is time order. */ period: string; /** * EVERY observed submission of this field in the bucket, whether or not the * value matched the resolved default — the dominance denominator. * * Rows written before this column existed carry `0` while `setCount > 0`; * {@link isLegacyBucket} detects that shape and the generation job then * treats the total as UNKNOWN and skips `default` suggestions for the group * (promote, which needs no denominator, still works). */ submissionCount: number; /** * Submissions whose value DIFFERED from the server-resolved default (the * promote signal). Always `<= submissionCount` on rows written by the * current ingestion path. */ setCount: number; /** * Size of the stored distinct-user set. When * {@link distinctUsersOverflowed} is true this is a LOWER BOUND (the set is * capped), never an estimate. */ distinctUserCount: number; /** JSON array of distinct user ids, capped (see the class doc). */ distinctUserIds: string; /** True once a distinct user was NOT added because the set is at its cap. */ distinctUsersOverflowed: boolean; /** * JSON object `serializedValue -> count` for histogram-eligible fields; * NULL when the field is count-only. Keys are bounded in number and length. */ valueHistogram: string | null; /** True once a sample was dropped because the bucket cap was reached. */ valueHistogramOverflowed: boolean; constructor(options?: FieldUsageCounterOptions); /** Parse the stored distinct-user set (guarded; junk parses as empty). */ getDistinctUserIds(): string[]; /** * Add a user to the distinct set, honoring the cap. At the cap the id is * NOT added and the overflow marker is set instead, keeping * {@link distinctUserCount} an honest lower bound. */ addDistinctUser(userId: string): void; /** * Parse the stored histogram (guarded; junk parses as empty). * * Returns a NULL-PROTOTYPE object. Histogram keys are user-supplied ids — * an `idType: 'text'` reference may legitimately be `constructor`, * `toString`, or `__proto__` — and on a plain object those inherit truthy * prototype values (so a missing bucket reads as present) or, for * `__proto__`, hit the prototype setter instead of creating an own key. * Both would silently corrupt counts. See {@link emptyHistogram}. */ getValueHistogram(): Record; /** * Record one histogram sample under an already-serialized key, honoring the * bucket cap (a NEW key past the cap is dropped and the overflow marker * set; existing keys keep counting). * * Bucket presence is an OWN-key test, never a truthiness/`undefined` read, * so prototype-shaped ids behave like any other key. */ recordHistogramSample(key: string): void; /** * Whether this bucket predates the {@link submissionCount} column (or was * corrupted): it records deviations without a total, so no honest dominance * ratio can be computed from it. The generation job skips `default` * suggestions for any group containing such a bucket. */ isLegacyBucket(): boolean; save(): Promise; delete(): Promise; private validateFieldUsageCounter; /** * Tenant write boundary (the FieldPolicy posture — no class-level * `@TenantScoped`, because the learning jobs legitimately operate * cross-tenant in trusted execution): inside a non-bypass tenant context a * caller may only touch rows of its own tenant; without a context (system/ * job execution) writes are trusted. * * Checked against BOTH the in-memory tenant and — for a row that already * exists — the PERSISTED one. The persisted check is what makes the boundary * real: bucket ids are deterministic and the deriving helper is exported, so * a foreign row is trivially addressable, and an in-memory-only check would * let a caller load it, re-stamp `tenantId` with its own, and adopt or delete * another tenant's counters (the #2047 FieldPolicy pattern). */ private assertRowOwnedByAmbientContext; /** The stored tenant for this row's id; `null` when it is not persisted. */ private getPersistedTenantId; } //# sourceMappingURL=FieldUsageCounter.d.ts.map