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 };
}