Skip to content

feat(gfql): searchAny WYSIWYG float + datetime search (#1695)#1700

Open
lmeyerov wants to merge 7 commits into
masterfrom
dev/gfql-searchany-wysiwyg
Open

feat(gfql): searchAny WYSIWYG float + datetime search (#1695)#1700
lmeyerov wants to merge 7 commits into
masterfrom
dev/gfql-searchany-wysiwyg

Conversation

@lmeyerov

@lmeyerov lmeyerov commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

What

Makes GFQL searchAny / search_nodes / search_edges match what the streamgl-viz inspector RENDERS for float and datetime columns — you search the displayed string, not the raw value (#1695). Adds WYSIWYG format call-params {floatPrecision, temporalFormat, tz}.

Floats were previously excluded from search because astype(str) diverges across engines (exponent regime + half-tie double-rounding). This makes the render byte-identical to the inspector and, per the parity-or-honest-NIE convention, keeps every engine either exact or an honest decline.

The decisive experiment (dgx cross-engine parity fuzzer)

Ran a float-render parity fuzzer on dgx-spark (all engines) with the real viz sprintf-js as the oracle. It settled the render and falsified the original "cross-engine decimal cast = parity" plan:

engine verdict
pandas "%.4f" % v direct exact — byte-identical to sprintf-js (3.28045"3.2805", 0.12345"0.1235")
pandas w/ np.round prepass (old draft) ❌ double-rounds 73/210 half-ties (-3.74825"-3.7482")
cuDF float→Decimal128 truncates (wrong on generic values, not just ties)
polars Decimal cast ❌ diverges at 5th-decimal ties + drops sign on -0.0000

There is no native GPU printf, and a per-cell UDF would be a forbidden host-bridge. Datetime render verified byte-identical to real moment.utc for the UTC path.

Design

  • Float: "%.4f" % v direct for fractional (single correctly-rounded conversion), str(int(v)) for whole. pandas-exact; cuDF/polars honest-NIE. inf"Infinity" (matches JS).
  • Datetime: reproduces the inspector's moment 'MMM D YYYY, h:mm:ss a z' via strftime, localized to a caller tz (per-timestamp DST-correct). pandas-only; cuDF/polars honest-NIE.
  • Options API {floatPrecision, temporalFormat, tz}: threaded through the cypher option-map (strict typed parse), the ast op, call-validation, and the python twins; defaults to the inspector (4 decimals; UTC).
  • Auto-gate (decision A, symmetric): float/datetime stay out of the auto-gate on every engine, so bare searchAny('...') behaves identically cross-engine; float/datetime are reached via explicit columns=. (@leo chose this over the "GPU raises" alternative.)

⚠️ Open decision for @lmeyerov

Datetime default tz='UTC' (chosen for determinism — a server-local default varies by deployment and would break the parity oracle / tests). You suggested "default to server time" — trivially switchable if you'd prefer that; the tz= call-param already delivers the localization knob either way.

Adversarial review (.agents/skills/review)

Multi-angle finders + verify. Three confirmed findings fixed: (1) inf/-inf render + RuntimeWarning; (2) am/pm lowercasing corrupting alpha tz abbrevs (AMTamT) — now word-bounded; (3) negative/bool floatPrecision + empty tz silently misrendering via the twins (which bypass the safelist) — added kernel-level validate_format_opts + tightened the safelist. Cross-engine-safety and parity-invariant hypotheses were refuted (clean).

Testing

All on dgx-spark (test-rapids-official:26.02-gfql, pandas + cuDF + polars + polars-gpu):

  • New test_search_any_float_wysiwyg, test_search_any_datetime_wysiwyg, test_search_any_format_options — pandas exact + cuDF/polars honest-NIE, half-tie/inf/tz/validation pinned.
  • Full conformance + cypher suites: 2041 passed / 7 skipped / 15 xfailed, no regressions.

🤖 Generated with Claude Code

lmeyerov and others added 7 commits July 5, 2026 21:36
…fied

_canonical_float_str reproduces the streamgl-viz inspector search render
(defaultFormat number/integer branch): WHOLE -> bare integer string
(exponent-free, 1e16 -> '10000000000000000'); FRACTIONAL -> fixed
`precision`-decimal. Both branches route through an exponent-free decimal-based
render so pandas/cuDF/polars can agree byte-for-byte (the astype(str) exponent +
half-boundary divergence is why floats were excluded). + _is_float_dtype.
pandas path unit-verified vs the inspector rule; cuDF branch drafted.

NOT yet wired into the gate/kernel and float exclusions NOT lifted — gated on
the dgx cross-engine parity fuzzer (cuDF/polars-gpu, never local) per the
parity-or-honest-NIE / no-silent-wrong rule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…as-exact (#1695 P1)

The dgx cross-engine float parity fuzzer (oracle = the viz's REAL sprintf-js)
settled the render and falsified the "cross-engine decimal cast = parity"
hypothesis:

- Correct fractional render is `"%.4f" % v` applied DIRECTLY to the raw double
  (single correctly-rounded conversion) — byte-identical to the inspector's
  sprintf-js (e.g. 3.28045->"3.2805", -3.74825->"-3.7483"). Whole floats
  (v%1==0, |v|<1e21) -> str(int(v)).
- The prior draft's `np.round(v,4)` prepass DOUBLE-ROUNDS and mis-renders 73/210
  half-tie cases (-3.74825->"-3.7482"); removed.
- The prior cuDF branch used `astype("decimal128[38,4]")` (string form), which
  RAISES in cudf 26.02; moot regardless — cuDF float->decimal128 TRUNCATES
  (wrong on generic values, not just ties) and polars' decimal cast diverges at
  5th-decimal ties + drops the sign on -0.0000. No native GPU printf; a per-cell
  UDF would be a host-bridge. So `_canonical_float_str` is now pandas-only.

Result (decision A, symmetric auto-gate): float WYSIWYG search is pandas-EXACT
via explicit `columns=[float]`; cuDF/polars honestly decline (NotImplementedError
/ None); float stays OUT of the auto gate on every engine (unchanged), so auto
behavior is cross-engine-identical. `search_any_mask` renders float columns
through `_canonical_float_str` instead of the divergent astype(str); adds a
forward-ready `float_precision` param (defaults to the inspector's 4).

Validated on dgx-spark (image test-rapids-official:26.02-gfql, all engines):
new test_search_any_float_wysiwyg + full conformance suites 302 passed / 1
skipped, pandas exact + cuDF/polars honest-NIE, half-tie regression pinned.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W5vkD2ZCyv3bmecBYoYYQy
…z-localized (#1695 P1b)

Explicit `columns=[<datetime>]` searches the inspector's date render on pandas:
`_canonical_datetime_str` reproduces moment `formatDate`'s
`'MMM D YYYY, h:mm:ss a z'` (MMM=%b, D=%-d, YYYY=%Y, h=%-I 12h, mm=%M, ss=%S,
a=lowercased am/pm, z=%Z tz-abbrev), localized to a caller `tz`.

Verified byte-identical to the viz's REAL moment.utc for the UTC path
(`moment.utc('2021-03-14T15:09:26Z').format(...)` == `"Mar 14 2021, 3:09:26 pm
UTC"`). `tz=` is Leo's localization call-param — pass the viewer's zone for
byte parity with what that viewer sees (per-timestamp DST-correct via zoneinfo:
PST/PDT, EST/EDT). Default `tz='UTC'` chosen for DETERMINISM (a server-local
default would vary by deployment and break the parity oracle) — FLAG for Leo,
who suggested server-time; trivially switchable.

Like float (P1), datetime is pandas-only: cuDF/polars honestly decline (their
native datetime->string + tz handling diverge; a per-cell UDF would be a
host-bridge). Datetime stays OUT of the auto gate on every engine (decision A,
symmetric). `search_any_mask` renders datetime columns via the new helper and
gains forward-ready `temporal_format`/`tz` params (default = inspector).

Validated on dgx-spark (all engines): new test_search_any_datetime_wysiwyg
(pandas exact incl. 12h/am-pm/tz-abbrev/null, cuDF/polars honest-NIE) + full
conformance 303 passed / 1 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W5vkD2ZCyv3bmecBYoYYQy
…poralFormat/tz (#1695 P4/P5)

Thread the #1695 format options end-to-end so callers control the WYSIWYG render
(default = the streamgl-viz inspector: 4 decimals; moment date format; UTC):

- cypher option map: `searchAny(a, term, {floatPrecision: N, temporalFormat: '...',
  tz: '...'})` — `_SEARCH_ANY_OPT_KEYS` + strict typed parsing (floatPrecision->int,
  temporalFormat/tz->string literal); unknown keys / wrong types raise as before.
- ast `search_any` op + `RowPipelineMixin.search_any` + call-validation safelist
  (float_precision:int, temporal_format/tz:string) + python twins
  `search_nodes`/`search_edges` all gain the three params and pass them to the kernel.
- polars dispatch unchanged: float/datetime decline regardless of these params, so
  the extra params are simply not read (string/int search is unaffected).

The `temporal` key from the original option sketch is left out (unclear semantics;
datetime is explicit-columns-only, so an enable-toggle is moot) — can add later.

Validated on dgx-spark (all engines): new test_search_any_format_options
(floatPrecision=2 -> "-3.75"; tz='America/Los_Angeles' -> "8:09:26 am"; twin
float_precision; strict-validation raises) + full conformance & cypher suites
2041 passed / 7 skipped / 15 xfailed, no regressions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W5vkD2ZCyv3bmecBYoYYQy
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W5vkD2ZCyv3bmecBYoYYQy
…, option validation (#1695 P8)

Three confirmed findings from the adversarial review (multi-angle finders + verify):

1. inf/-inf floats rendered "inf"/"-inf" (+ a RuntimeWarning from `inf % 1`) but
   the inspector shows "Infinity"/"-Infinity" (node-verified: sprintf('%.4f',Inf)
   == "Infinity"). Now handled explicitly before the `% 1` split — correct render,
   no warning.
2. The am/pm lowercasing was a blind global `str.replace("AM"/"PM")` that corrupted
   an alpha tz abbreviation (America/Manaus -> "AMT" became "amT") and literal text
   in a custom temporal_format. Now word-bounded (`\bAM\b`/`\bPM\b`), so only the
   standalone am/pm token is lowercased.
3. Negative / bool `float_precision` (and empty tz / temporal_format) silently
   misrendered via the python twins, which bypass the call safelist (`"%.*f" % -1`
   clamps to 0; `tz_convert('')` raises an opaque IndexError). Added
   `validate_format_opts` at the kernel (the single choke point all surfaces pass
   through) for a consistent GFQLValidationError, and tightened the safelist
   validator to `is_nonneg_int` so the ASTCall/cypher path (all engines) rejects
   them before dispatch too.

Also documents the astronomically-rare whole-float >= 2**53 shortest-round-trip
limitation (str(int(v)) vs JS String()). The review's cross-engine-safety and
parity-invariant hypotheses were refuted (no cuDF/polars render path, no silent
divergence, no host-bridge).

Regression pins added (inf render + no-RuntimeWarning; twin option validation).
dgx all-engines: 1436 search/validation/lowering passed; conformance 304/1 skip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W5vkD2ZCyv3bmecBYoYYQy
Interactive dgx benchmark ("typing into a search window", 100K/1M rows, mixed
columns) surfaced two render hot-spots (the render re-runs per keystroke):

- Float: replaced the np.char.mod/numpy-masked-scatter render with a plain python
  list-comp — measured FASTER (1M: ~305ms -> the masked-scatter was ~357ms; the
  simple loop wins because it avoids several full-array passes + object boxing).
  Byte-identical output (differential vs the prior element-wise semantics, all
  precisions + inf/NaN/nullable).
- Datetime: pandas `dt.strftime` is a ~20x-slow per-element path (~2.2s/1M). For
  the default format in UTC (the common, deterministic case) assemble the render
  vectorized from `.dt` components + a month-name lookup — byte-identical to
  strftime (verified incl. midnight->"12:00:00 am", noon, single-digit day, NaT).
  Custom temporal_format / non-UTC tz keep the strftime path (correct, slower).

Result (per-keystroke @ 1M): datetime 3111ms -> 1512ms (2.05x), float 674ms ->
557ms. All searchAny conformance tests still pass on dgx (all engines).

Note: the render is TERM-INDEPENDENT, so the real interactive win is a base-graph
search-text index (render once, slice to the filtered view) — quantified
separately (blob index: 2903ms -> 68ms/keystroke @1m). Tracked for follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W5vkD2ZCyv3bmecBYoYYQy
@lmeyerov

lmeyerov commented Jul 6, 2026

Copy link
Copy Markdown
Contributor Author

Perf: interactive latency + the base-graph index question

Benchmarked the "typing into a search window" scenario on dgx-spark (pandas, 100K/1M rows, 8 mixed-typed columns), since each keystroke dispatches a fresh query but the columns+data are fixed.

Render optimizations landed (1ce40e6)

The per-keystroke render was the hot spot:

  • Float: the np.char.mod/numpy-masked-scatter render was slower than a plain list-comp (multiple full-array passes + object boxing) — reverted to a list-comp, byte-identical, ~15% faster.
  • Datetime: pandas dt.strftime is a ~20×-slow per-element path (~2.2s/1M). For the default format in UTC (common, deterministic) I now assemble the render vectorized from .dt components + a month lookup — byte-identical to strftime (verified incl. midnight/noon/single-digit-day/NaT). Custom temporalFormat / non-UTC tz keep strftime.

Per-keystroke @ 1M: datetime 3111ms → 1512ms (2.05×), float 674ms → 557ms.

The index question (base vs view) — quantified

The render is term-independent, so it should be computed once and reused. Measured @ 1M / 8 cols:

approach per-keystroke vs today
(A) today — render+match every keystroke 2903 ms
(B) per-column render cache — render once, N Contains 439 ms 6.6×
(C) concatenated search-blob index — render+join once, 1 Contains 68 ms 43×
  • One-time build (term-independent): blob 567 ms; per-column cache 2480 ms.
  • Base vs view: slicing the base blob to a 50% filtered view = 8 ms slice + 36 ms keystroke — so the index lives at the base-graph level and slices to the filtered/rendered view; no per-keystroke re-render.

Recommendation: a base-graph search-text index — a single concatenated, lowercased WYSIWYG search-text column built once per (data, format-options), sliced to the active view, so each keystroke is one Contains. That's the 43× win and it's what turns 1M-row interactive search from ~3s to ~68ms/keystroke. It's an architectural addition (where the index lives on the Plottable, invalidation on data/option change, integration with the chain/viz dispatch) — proposing to track it as a follow-up unless we want it in this PR.

(The correctness feature in this PR is unchanged and CI-green; the render opts are pure speedups with byte-identical output.)

@lmeyerov

lmeyerov commented Jul 6, 2026

Copy link
Copy Markdown
Contributor Author

Search-text index follow-up filed: #1703 (base-graph blob index, 43× interactive win, cross-referenced to the #531/#1658 CREATE INDEX stack per @lmeyerov). The render speedups in this PR are the standalone, byte-identical part.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant