File size: 14,018 Bytes
56e1f2f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 | // ---------------------------------------------------------------------------
// customer-grid / rankOps.ts
// Wave 2026-08-02 item 10 (contract C-OPS) β the RANK operators, pure.
//
// Every other filter operator answers a question about ONE cell: `revenue > 5000`
// is true or false for a row on its own. A rank operator cannot be answered that
// way at all β "is this customer in the top 10" depends on which OTHER rows are
// in the question. That is the whole design problem, and it is why this is a
// separate resolution PASS rather than eight more cases in `matchFilter`:
//
// 1. strip every rank leaf from the tree (stripRankLeaves)
// 2. evaluate the residue -> that match-set is the DOMAIN (the caller does)
// 3. rank the domain, once per column, and hand each leaf its slice (this file)
// 4. evaluate the FULL tree, rank leaves as membership tests (useVisibleRows)
//
// So "top 10 of agent Naomi" narrows by agent FIRST and then takes ten, which is
// what the sentence says and not what a naive per-row implementation would do.
//
// THE FAIL-CLOSED LAW, restated because a rank leaf is where it bites hardest: a
// leaf this file cannot answer gets an EMPTY set, never a missing one. A missing
// set would land in `evalNode`'s "no answer" branch, and the tempting reading of
// "no answer" is "no narrowing" β which shows every record under a count nobody
// would doubt. The unanswerable ones are COUNTED instead, so the empty table has
// a marker beside it saying why.
// ---------------------------------------------------------------------------
import type { Field, FieldType, FilterNode, FilterRule, Row } from "./types";
import { isFilterGroup, isMeasureRule, isRankOp, isRuleActive } from "./types";
/** `leaf -> the pids that satisfy it`. Keyed by the leaf OBJECT: a rank leaf carries no id of
* its own (only measure rules do), and keying by position would silently re-associate every
* answer the moment a condition was deleted β the trap `FilterRule.id` exists to avoid. The
* tree is stable for the life of one pipeline run, which is exactly this map's lifetime. */
export type RankSets = Map<FilterRule, Set<number>>;
export interface RankResolution {
sets: RankSets;
/**
* ACTIVE rank leaves that could not be answered AT ALL β the same distinction
* `unresolvedConditions` draws for dates and cohorts, and for the same reason: they match
* nothing, and "0 records" with no marker is indistinguishable from a filter that genuinely
* matches nobody. Three causes, all of them counted:
* - the column is gone, or is not of a type that can be ranked
* - the column has no numeric value ANYWHERE in a non-empty domain (a measure column
* whose values have not arrived β cold store, or a window still resolving)
* - the operator's value is junk (a saved view holding `inQuartile 9`)
*/
unanswerable: number;
}
/** The resolution of a tree with no rank leaves in it. Shared, never mutated. */
export const NO_RANKING: RankResolution = { sets: new Map(), unanswerable: 0 };
/**
* Types a rank operator may be applied to.
*
* The engine's own copy, deliberately β `useVisibleRows.isNumericType` carries the same list
* with the same comment, because that file is the self-contained reference the Python port
* mirrors. Two copies pinned by a gate beat one import that quietly widens both.
*/
function isRankableType(t: FieldType): boolean {
return t === "currency" || t === "int" || t === "pct" || t === "rating" || t === "formula";
}
/**
* True when this leaf asks a RANK question.
*
* A MEASURE leaf is excluded by construction even if it somehow carried a rank op: a measure
* condition is answered by a server-side pid set, so ranking it client-side would be a second,
* disagreeing answer to the same question. (The builder cannot produce one β measure leaves
* offer `MEASURE_OPS` β so this is the backstop, not the behaviour.)
*/
export function isRankRule(node: FilterNode): boolean {
return (
!isFilterGroup(node) && !isMeasureRule(node) && isRankOp((node as FilterRule).op)
);
}
/** Does this tree contain ANY rank leaf, active or not? The pipeline's cheap early-out: with
* no rank leaf, not one line of this file runs and the engine is byte-for-byte as shipped. */
export function hasRankLeaf(nodes: FilterNode[]): boolean {
for (const node of nodes ?? []) {
if (isFilterGroup(node)) {
if (hasRankLeaf(node.children)) return true;
continue;
}
if (isRankRule(node)) return true;
}
return false;
}
/**
* The tree with every rank leaf removed β the question whose answer is the ranking DOMAIN.
*
* A group left with no children is DROPPED rather than kept empty. Both behave identically
* today (`evalNode` returns null for a group with no active children, and null is ignored),
* but an empty group is a shape the rest of the engine never otherwise sees, and shapes that
* only appear in one code path are how a future edit acquires a special case.
*/
export function stripRankLeaves(nodes: FilterNode[]): FilterNode[] {
const out: FilterNode[] = [];
for (const node of nodes ?? []) {
if (isFilterGroup(node)) {
const children = stripRankLeaves(node.children);
if (children.length) out.push({ conj: node.conj, children });
continue;
}
if (isRankRule(node)) continue;
out.push(node);
}
return out;
}
/** Every ACTIVE rank leaf, in tree order. Inactive ones (a `topN` with no N typed yet) are
* left out for the same reason every other half-typed condition is: they are not asking. */
export function activeRankLeaves(nodes: FilterNode[]): FilterRule[] {
const out: FilterRule[] = [];
for (const node of nodes ?? []) {
if (isFilterGroup(node)) {
out.push(...activeRankLeaves(node.children));
continue;
}
if (!isRankRule(node)) continue;
const rule = node as FilterRule;
if (isRuleActive(rule)) out.push(rule);
}
return out;
}
/** One domain row reduced to what ranking needs. */
interface Ranked {
pid: number;
v: number;
}
/**
* The domain's rows that HAVE a value for `colId`, ordered.
*
* β `Number(raw)` is used and `toNum` is NOT: the engine's coercion helper turns anything
* unparseable into 0, which is right for a comparison (`x > 5` against a blank is false either
* way) and catastrophic here β every blank customer would enter the ranking as a real zero and
* fill the bottom of every "bottom 10". A blank is not a zero; it is an absence, and an absence
* has no rank. Note that a genuine 0 stays in: `raw === ""` is the test, not falsiness.
*
* The tie-break is `pid` ASCENDING in BOTH directions, never a reversal of the whole order:
* "keep exactly N, tie-break pid ascending" has to mean the same N whichever end you ask from,
* or "top 10" and "bottom 10" of a ten-row table would not be the same ten rows.
*/
function order(domain: Row[], colId: string, dir: "asc" | "desc"): Ranked[] {
const out: Ranked[] = [];
for (const r of domain) {
const raw = r[colId];
if (raw == null || raw === "") continue;
const v = typeof raw === "number" ? raw : Number(raw);
if (!Number.isFinite(v)) continue;
out.push({ pid: r.pid, v });
}
out.sort((a, b) => (a.v === b.v ? a.pid - b.pid : dir === "asc" ? a.v - b.v : b.v - a.v));
return out;
}
/** The leaf's rhs as a whole number inside [lo, hi], or null when it is junk. Null is an
* UNANSWERABLE leaf, never a defaulted one β silently reading `inQuartile 9` as 4 would answer
* a question the view does not ask. */
function bound(value: string, lo: number, hi: number): number | null {
const n = Number(value);
if (!Number.isFinite(n)) return null;
const i = Math.trunc(n);
if (i !== n || i < lo || i > hi) return null;
return i;
}
/**
* Which of `k` equal slices of the RANK AXIS row `index` falls in. Bucket 1 is the lowest
* slice and bucket `k` the highest, so `inQuartile 4` is the top quarter β which is how the
* operator reads.
*
* `floor(i * k / n) + 1` β the slice of [0, 1) that i/n lands in β rather than the literal
* `floor(i / ceil(n / k)) + 1` the contract's "ceil split" first suggests. The difference is
* not cosmetic: fixed ceil-sized buckets leave the TOP one EMPTY whenever n sits a little
* above a multiple of k (six rows into quarters gives 2/2/2/0), so "the top quarter" would
* match nobody while three lower quarters were full. This form gives 2/1/2/1 β every bucket
* non-empty, sizes differing by at most one. Amendment booked in the wave doc.
*
* Buckets are by RANK INDEX, not by value: two rows sharing a value can land either side of a
* boundary, broken by pid ascending. That is what "equal-size buckets" means and it is the
* contract's explicit choice over value interpolation β said here because the builder's helper
* line has to say it to the user in one sentence.
*/
function slice(index: number, n: number, k: number): number {
return Math.min(k, Math.floor((index * k) / n) + 1);
}
/** How many rows `pct` percent of `n` is. Ceil: "the top 10%" of 43 must not be 4.3, and
* rounding DOWN would make the top 1% of anything under 100 rows match nobody. */
function pctCount(n: number, pct: number): number {
return Math.min(n, Math.max(1, Math.ceil((n * pct) / 100)));
}
/**
* Resolve every ACTIVE rank leaf against the domain.
*
* `domain` is the rows matching everything EXCEPT the rank leaves β the caller computes it, so
* that this file never needs to know how a cohort or a measure condition is answered.
*/
export function resolveRankLeaves(
filters: FilterNode[],
domain: Row[],
fieldByKey: Map<string, Field>
): RankResolution {
const leaves = activeRankLeaves(filters);
if (!leaves.length) return NO_RANKING;
const sets: RankSets = new Map();
let unanswerable = 0;
// Several leaves can name the same column ("top 10" AND "above average" by revenue), and the
// sort is the expensive half. Cached per column PER DIRECTION β see `order`.
const cache = new Map<string, Ranked[]>();
const ordered = (colId: string, dir: "asc" | "desc"): Ranked[] => {
const ck = `${dir}:${colId}`;
let list = cache.get(ck);
if (!list) {
list = order(domain, colId, dir);
cache.set(ck, list);
}
return list;
};
const refuse = (leaf: FilterRule) => {
sets.set(leaf, new Set());
unanswerable += 1;
};
for (const leaf of leaves) {
const field = fieldByKey.get(leaf.colId);
// A rank op on a column that is gone, or on text/date/select. NOT the engine's usual
// "an operator that does not apply to this type is always-true" case, and the divergence
// is deliberate: always-true is safe for `contains` on a number (it narrows nothing and
// asks nothing), but a rank operator is a NARROWING question, and answering "everybody"
// to "who is in the top ten" is the widening sin wearing a plausible face.
if (!field || !isRankableType(field.type)) {
refuse(leaf);
continue;
}
const asc = ordered(leaf.colId, "asc");
if (!asc.length) {
// No value anywhere. With an EMPTY domain that is just "your other conditions matched
// nobody" β already explained by the rows β so it is not counted twice. With a populated
// domain it means the column itself has no values: a measure column whose derived cells
// have not arrived, which is precisely the state that must not read as "top 10 of
// nothing = everything".
sets.set(leaf, new Set());
if (domain.length) unanswerable += 1;
continue;
}
const n = asc.length;
let keep: Ranked[] | null = null;
switch (leaf.op) {
case "topN": {
const k = bound(leaf.value, 1, 10000);
keep = k == null ? null : ordered(leaf.colId, "desc").slice(0, k);
break;
}
case "bottomN": {
const k = bound(leaf.value, 1, 10000);
keep = k == null ? null : asc.slice(0, k);
break;
}
case "inTopPct": {
const p = bound(leaf.value, 1, 100);
keep = p == null ? null : ordered(leaf.colId, "desc").slice(0, pctCount(n, p));
break;
}
case "inBottomPct": {
const p = bound(leaf.value, 1, 100);
keep = p == null ? null : asc.slice(0, pctCount(n, p));
break;
}
case "aboveAvg":
case "belowAvg": {
// The mean of the DOMAIN's values, not of the whole table β the same residue rule
// every other rank op follows, so "above average, among agent Naomi's customers"
// means what it says.
const mean = asc.reduce((sum, e) => sum + e.v, 0) / n;
keep = asc.filter((e) => (leaf.op === "aboveAvg" ? e.v > mean : e.v < mean));
break;
}
case "inQuartile":
case "inDecile": {
const k = leaf.op === "inQuartile" ? 4 : 10;
const want = bound(leaf.value, 1, k);
// FEWER ROWS THAN BUCKETS is a question the data cannot answer, and it is the one
// place where inventing a definition would be indefensible: with six ranked rows and
// ten deciles, SOME decile is empty whatever the rule, so any formula chooses which
// asks go unanswered. Refusing says so out loud (and gets counted) instead of quietly
// returning nobody for "the top decile" and everybody's guess for the rest.
keep = want == null || n < k ? null : asc.filter((_, i) => slice(i, n, k) === want);
break;
}
default:
// A rank op this file does not implement. Unreachable through `RANK_OPS`, and it
// REFUSES rather than falling through β the one behaviour that keeps adding an
// operator to the vocabulary from silently widening every view that uses it.
keep = null;
}
if (keep == null) {
refuse(leaf);
continue;
}
sets.set(leaf, new Set(keep.map((e) => e.pid)));
}
return { sets, unanswerable };
}
|