Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
<!-- Do Not Erase This Section - Used for tracking unreleased changes -->

### Added
- **GFQL searchAny WYSIWYG float + datetime search (#1695)**: `searchAny` / `search_nodes` / `search_edges` now match float and datetime columns against **what the streamgl-viz inspector renders** (the displayed string, not the raw value), via explicit `columns=`. Floats render byte-identically to the inspector's `sprintf('%.4f')` (whole floats → bare integer string), fixing the exponent/half-tie divergence that had excluded them; datetimes render the inspector's moment format `'MMM D YYYY, h:mm:ss a z'` localized to a caller timezone. New WYSIWYG format call-params `{floatPrecision, temporalFormat, tz}` (cypher option-map + python twins, strict-validated), defaulting to the inspector (4 decimals; UTC). Cross-engine parity-or-honest-NIE: **pandas-exact** (dgx-verified against the real `sprintf-js`/`moment`), while cuDF/polars honestly decline float/datetime (their native decimal/temporal render can't reproduce pandas and a per-cell UDF would be a host-bridge). Float/datetime stay out of the auto-gate on every engine, keeping auto-search cross-engine-symmetric.
- **GFQL viz-filter-pipeline acceptance suite + regression benchmark (viz-filter L3)**: `test_viz_pipeline_conformance.py` — curated full-panel pipelines (node+edge filters, exclusion-dominates composition, `(pred OR IS NULL)` keep-null leaves, both EXISTS prune-isolated flavors, searchAny composition, deterministic paging), graph-state prune shapes with exact node+edge pins, a 40-seed panel-state fuzzer with an independent plain-pandas second oracle, and a case/regex/unicode trick matrix (ß/İ full-case-mapping pins, metachar literal-vs-regex, null cells, Categorical) — all parity-or-NIE across pandas/cuDF/polars/polars-gpu. `benchmarks/gfql/viz_filter_pipeline.py` — six streamgl-viz panel scenarios (filters, keep-self GRAPH prune, EXISTS prune, node/edge search, combined) at 100K/1M/10M with native-frame-per-engine fairness, an NIE-tolerant matrix, and JSON receipts (first receipt: 100k × 4 engines, everything within the ~350ms interactive reference except pandas combined). Documented findings from the first runs: the same-path WHERE route dedupes parallel edges (diverges from the panel algebra's edge multiplicity — pinned + tracked), and edge-alias searchAny declines on polars (tracked).
- **GFQL Cypher `searchAny(entity, term[, opts])` cross-column search predicate + `g.search_nodes()`/`g.search_edges()` (viz-filter L2, native on all four engines)**: True where ANY of the entity's columns matches the term — the streamgl-viz inspector's table-search semantics as a composable WHERE predicate: OR across columns; case-insensitive substring by DEFAULT (case-folded, never regex — the common call avoids every engine regex limit); regex opt-in obeying the same per-engine decline rules as `=~`; dtype gate AS SEMANTICS (string columns always; integer columns iff the term is a numeric literal, per the inspector's `/^[0-9.-]+$/` gate; floats/dates/booleans reachable via the explicit `columns:` list). Options map `{caseSensitive, regex, columns}` is strict-validated (unknown keys error listing the valid ones); unbound aliases and missing explicit columns error clearly; null cells never match. Lowered like the pattern-predicate markers (a `search_any` row op + fresh marker column), so it composes through AND/OR/NOT and different node/edge terms coexist in one pipeline. Per-column matching reuses the parity-hardened `Contains` predicate on pandas/cuDF and a lowercase-fold/any_horizontal lowering on polars; oracle-pinned + 4-engine parity-or-NIE conformance cases. Python twins `g.search_nodes(term, columns=, case_sensitive=, regex=)` / `g.search_edges(...)` filter their own table and return a Plottable (polars-frame twins decline honestly for now — use the cypher op). Honest declines (NIE, use engine='pandas'): edge-alias searchAny on polars, and explicit columns beyond string/int/bool dtypes on polars AND cuDF (incl. floats: repr diverges across engines — dgx-probed).
- **GFQL Cypher `EXISTS { <pattern> }` pattern-existence subqueries (openCypher-standard), native on all four engines**: `WHERE EXISTS { (n)-[:R]->() }` and `WHERE NOT EXISTS { (n)--() }` now parse and run — the declarative prune-isolated building blocks for the streamgl-viz filter pipeline. An `EXISTS` body reuses the existing pattern-predicate lowering wholesale (`semi_apply_mark` / `anti_semi_apply` row ops), so pandas/cuDF worked immediately; the polars engine gains NATIVE lowerings for the semi-apply family (correlated key sets computed by the polars chain executor's named-flag columns; order-preserving `is_in` joins) plus `rows(binding_ops=...)` for the single-entity row table — previously all honest-NIE. Aliases introduced inside the braces are existentially scoped (`EXISTS { (n)--(m) }` allowed with `m` unbound outside — bare pattern predicates keep the conservative guard), inline property maps work, and the one supported inner `WHERE` form is endpoint inequality — `EXISTS { (n)--(m) WHERE m <> n }`, the drop-self-loop prune-isolated flavor (pandas/cuDF filter the correlated bindings; polars excludes self-loop edges, which is exactly the `m <> n` witness). Both prune flavors are oracle-pinned in the conformance matrix on a self-loop discriminator graph, 4-engine parity-or-NIE. Honest declines with clear errors: `EXISTS` in RETURN/WITH projections, general inner `WHERE`, multi-pattern bodies, full `MATCH..RETURN` subquery bodies, multi-alias correlation on polars.
Expand Down
14 changes: 10 additions & 4 deletions graphistry/compute/ComputeMixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -487,12 +487,15 @@ def get_topological_levels(
out_df = safe_merge(g2_base._nodes, levels_df, on=g2_base._node, how='left')
return self.nodes(out_df)

def search_nodes(self, term, columns=None, case_sensitive=False, regex=False):
def search_nodes(self, term, columns=None, case_sensitive=False, regex=False,
float_precision=4, temporal_format=None, tz="UTC"):
"""Keep nodes where ANY column matches ``term`` (viz-filter L2 inspector
semantics: OR across columns; case-insensitive substring default; regex
opt-in; string columns always, integer columns iff the term is a numeric
literal — floats/dates via explicit ``columns=`` on pandas ONLY: cuDF
declines them, its float/temporal stringification diverges from pandas).
``float_precision``/``temporal_format``/``tz`` are the #1695 WYSIWYG format
options for explicit float/datetime columns (default = the inspector).
pandas/cuDF native; polars frames raise NotImplementedError (use the
cypher ``search_any`` op).
"""
Expand All @@ -506,7 +509,8 @@ def search_nodes(self, term, columns=None, case_sensitive=False, regex=False):
"search_nodes is not yet native on polars frames; use the cypher "
"search_any op or engine='pandas'")
mask = search_any_mask(
df, term, case_sensitive=case_sensitive, regex=regex, columns=columns)
df, term, case_sensitive=case_sensitive, regex=regex, columns=columns,
float_precision=float_precision, temporal_format=temporal_format, tz=tz)
if mask is None:
raise GFQLValidationError(
ErrorCode.E108,
Expand All @@ -515,7 +519,8 @@ def search_nodes(self, term, columns=None, case_sensitive=False, regex=False):
suggestion="List only columns present on the nodes table.")
return self.nodes(df[mask])

def search_edges(self, term, columns=None, case_sensitive=False, regex=False):
def search_edges(self, term, columns=None, case_sensitive=False, regex=False,
float_precision=4, temporal_format=None, tz="UTC"):
"""Keep edges where ANY column matches ``term`` — see :meth:`search_nodes`."""
from graphistry.compute.gfql.search_any import search_any_mask
from graphistry.compute.exceptions import ErrorCode, GFQLValidationError
Expand All @@ -527,7 +532,8 @@ def search_edges(self, term, columns=None, case_sensitive=False, regex=False):
"search_edges is not yet native on polars frames; use the cypher "
"search_any op or engine='pandas'")
mask = search_any_mask(
df, term, case_sensitive=case_sensitive, regex=regex, columns=columns)
df, term, case_sensitive=case_sensitive, regex=regex, columns=columns,
float_precision=float_precision, temporal_format=temporal_format, tz=tz)
if mask is None:
raise GFQLValidationError(
ErrorCode.E108,
Expand Down
14 changes: 13 additions & 1 deletion graphistry/compute/ast.py
Original file line number Diff line number Diff line change
Expand Up @@ -1688,18 +1688,30 @@ def search_any(
case_sensitive: bool = False,
regex: bool = False,
columns: Optional[Sequence[str]] = None,
float_precision: Optional[int] = None,
temporal_format: Optional[str] = None,
tz: Optional[str] = None,
) -> ASTCall:
"""Annotate active rows with a cross-column search marker (viz-filter L2):
``out_col`` True where ANY of ``alias``'s columns matches ``term`` — OR across
columns, case-insensitive substring default, regex opt-in, dtype-gated (string
columns always; integer columns iff numeric-literal term)."""
columns always; integer columns iff numeric-literal term). ``float_precision``
/ ``temporal_format`` / ``tz`` are the #1695 WYSIWYG format options for explicit
float / datetime columns (default = the streamgl-viz inspector: 4 decimals; the
moment date format; UTC)."""
params: Dict[str, Any] = {"alias": alias, "term": term, "out_col": out_col}
if case_sensitive:
params["case_sensitive"] = True
if regex:
params["regex"] = True
if columns is not None:
params["columns"] = list(columns)
if float_precision is not None:
params["float_precision"] = float_precision
if temporal_format is not None:
params["temporal_format"] = temporal_format
if tz is not None:
params["tz"] = tz
return ASTCall("search_any", params)


Expand Down
14 changes: 13 additions & 1 deletion graphistry/compute/gfql/call/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,12 @@ def is_list_of_dicts(v: object) -> bool:
return isinstance(v, list) and all(isinstance(item, dict) for item in v)


def is_nonneg_int(v: object) -> bool:
# bool is an int subclass in Python — exclude it, and negatives (#1695
# floatPrecision: a `"%.*f" % -1` clamp would silently misrender).
return is_int(v) and not isinstance(v, bool) and v >= 0 # type: ignore[operator]


def is_where_rows_expr(v: object) -> bool:
return is_non_empty_string(v) and _where_rows_expr_parser_parse_ok(str(v).strip())

Expand Down Expand Up @@ -407,7 +413,8 @@ def _semi_apply_mark_added_node_cols(params: Dict[str, object]) -> Set[str]:
),

'search_any': _safelist_entry(
{'alias', 'term', 'out_col', 'case_sensitive', 'regex', 'columns'},
{'alias', 'term', 'out_col', 'case_sensitive', 'regex', 'columns',
'float_precision', 'temporal_format', 'tz'},
required_params={'alias', 'term', 'out_col'},
param_validators={
'alias': is_non_empty_string,
Expand All @@ -416,6 +423,11 @@ def _semi_apply_mark_added_node_cols(params: Dict[str, object]) -> Set[str]:
'case_sensitive': is_bool,
'regex': is_bool,
'columns': is_non_empty_list_of_strings,
# #1695 WYSIWYG format options (default = inspector): float decimals,
# datetime strftime pattern, and localization tz.
'float_precision': is_nonneg_int,
'temporal_format': is_non_empty_string,
'tz': is_non_empty_string,
},
description='Annotate active rows with a cross-column search marker (OR across columns)',
schema_effects=_schema_effects(adds_node_cols=_semi_apply_mark_added_node_cols),
Expand Down
24 changes: 22 additions & 2 deletions graphistry/compute/gfql/cypher/lowering.py
Original file line number Diff line number Diff line change
Expand Up @@ -3851,7 +3851,12 @@ def _append_page_ops(
r"\s*(?:,\s*\{([^{}]*)\})?\s*\)",
re.IGNORECASE,
)
_SEARCH_ANY_OPT_KEYS = {"casesensitive": "case_sensitive", "regex": "regex", "columns": "columns"}
_SEARCH_ANY_OPT_KEYS = {
"casesensitive": "case_sensitive", "regex": "regex", "columns": "columns",
# #1695 WYSIWYG format options (default = the streamgl-viz inspector)
"floatprecision": "float_precision", "temporalformat": "temporal_format", "tz": "tz",
}
_SEARCH_ANY_STR_OPT_RE = re.compile(r"^'([^']*)'$|^\"([^\"]*)\"$")
_SEARCH_ANY_COLUMNS_RE = re.compile(
r"^\[\s*(?:'[^']*'|\"[^\"]*\")(?:\s*,\s*(?:'[^']*'|\"[^\"]*\"))*\s*\]$")
_SEARCH_ANY_COL_ITEM_RE = re.compile(r"'([^']*)'|\"([^\"]*)\"")
Expand Down Expand Up @@ -3898,7 +3903,22 @@ def _parse_search_any_opts(opts_text: str, *, line: int, column: int) -> Dict[st
field="where", value=val_text, line=line, column=column,
)
out[canon] = low == "true"
else:
elif canon == "float_precision":
if not re.fullmatch(r"\d+", val_text):
raise _unsupported(
"searchAny option 'floatPrecision' must be a non-negative integer",
field="where", value=val_text, line=line, column=column,
)
out[canon] = int(val_text)
elif canon in ("temporal_format", "tz"):
sm = _SEARCH_ANY_STR_OPT_RE.match(val_text)
if not sm:
raise _unsupported(
f"searchAny option {key!r} must be a string literal",
field="where", value=val_text, line=line, column=column,
)
out[canon] = sm.group(1) if sm.group(1) is not None else sm.group(2)
else: # columns
if not _SEARCH_ANY_COLUMNS_RE.match(val_text):
raise _unsupported(
"searchAny option 'columns' must be a list of string literals",
Expand Down
6 changes: 5 additions & 1 deletion graphistry/compute/gfql/row/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -4180,6 +4180,9 @@ def search_any(
case_sensitive: bool = False,
regex: bool = False,
columns: Optional[List[str]] = None,
float_precision: int = 4,
temporal_format: Optional[str] = None,
tz: str = "UTC",
) -> "Plottable":
"""Cross-column search marker (viz-filter L2, panel-algebra D2): ``out_col`` is
True where ANY of the alias's columns matches ``term`` — OR across columns,
Expand All @@ -4204,7 +4207,8 @@ def search_any(
sub = left_df[[c for c in left_df.columns
if not c.startswith("__gfql_") and c != alias]]
mask = search_any_mask(
sub, term, case_sensitive=case_sensitive, regex=regex, columns=columns)
sub, term, case_sensitive=case_sensitive, regex=regex, columns=columns,
float_precision=float_precision, temporal_format=temporal_format, tz=tz)
if mask is None:
raise GFQLValidationError(
ErrorCode.E108,
Expand Down
Loading
Loading