From e56d7caf7f18ec246c23f90f7c3721aa2913109e Mon Sep 17 00:00:00 2001 From: Esteban Zimanyi Date: Mon, 14 Sep 2026 15:30:59 +0200 Subject: [PATCH] Give each position operator its SQL names by class A position operator has one SQL name per class of its operands, the class followed by the position: `<<` is setLeft, spanLeft, spansetLeft, tboxLeft, stboxLeft and tpcboxLeft, a temporal operand taking the class of its bounding box (MobilityDB#2717). The portable mapping holds the sixteen position operators in `positionFamilies`, each with its position, the stem those names and the MEOS C functions share (left_set_set, left_tspatial_tspatial). `families`, `byOperator` and `byBareName` carry the 25 operators that have one bare name. `attach_position_names` derives the names by class from the @sqlfn tags of the functions whose @sqlop is the operator, once the @sqlfn map is attached, into `portableAliases.positionNames`: 64 names for the 16 operators over MobilityDB master. A name that is not its class followed by the position, or an operator that no function carries, raises. The parity audit lists each position operator under `byPosition`, backed by the MEOS family its position prefixes and reported with its SQL names by class. The schema, the tests and the documentation cover the new section; the documentation's family table carries the spelled-out comparison names. --- README.md | 8 +- docs/cross-repo-handoff.md | 6 +- docs/portable-aliases.md | 62 +++++++++---- meta/portable-aliases.json | 103 ++++++---------------- meta/portable-aliases.schema.json | 22 ++++- parser/portable.py | 59 ++++++++++++- run.py | 12 ++- tests/test_portable.py | 141 +++++++++++++++++++++++++----- tests/test_portable_parity.py | 33 ++++++- tools/portable_parity.py | 35 +++++++- 10 files changed, 354 insertions(+), 127 deletions(-) diff --git a/README.md b/README.md index db87923..895deed 100644 --- a/README.md +++ b/README.md @@ -228,7 +228,9 @@ Manual annotations (ownership rules, additional documentation, deprecation flags bare-name mapping that MobilityDB now registers natively (PR #1075). The pipeline folds it into the catalog as `portableAliases` (with `byOperator` / `byBareName` lookups), so **every binding/engine generates the identical -bare names** and a user learns one reference and assumes the rest. +bare names** and a user learns one reference and assumes the rest. A +position operator has one name per class instead (`setLeft` … `stboxLeft`), +which the catalog derives from the `@sqlfn` tags as `positionNames`. It is curated canonical data, kept verbatim (only bijective lookups are derived — no C-symbol guessing; upstream aliases reuse each operator's own @@ -236,8 +238,8 @@ backing function, equivalence by construction). The mapping is type-agnostic and applies to **every** temporal type family — `temporal`, `geo`, `cbuffer`, `npoint`, `pose`, `rgeo` are all in scope and must not be excluded from any parity headline. `python tools/portable_parity.py` -audits it against the catalog — currently **29/29 = 100%** backed (verified, -no guessing). See [`docs/portable-aliases.md`](docs/portable-aliases.md). +audits it against the catalog, flagging every operator no catalog family backs +(no guessing). See [`docs/portable-aliases.md`](docs/portable-aliases.md). ## OpenAPI generation diff --git a/docs/cross-repo-handoff.md b/docs/cross-repo-handoff.md index 03175e3..18eb59a 100644 --- a/docs/cross-repo-handoff.md +++ b/docs/cross-repo-handoff.md @@ -17,7 +17,7 @@ python tools/portable_parity.py # -> output/meos-portable-parity.json (bare-nam | Artifact | Contents | |---|---| -| `meos-idl.json#/portableAliases` | canonical operator→bare-name dialect: `byOperator`, `byBareName`, `families`, `explicitBacking`, `scope` | +| `meos-idl.json#/portableAliases` | canonical operator→bare-name dialect: `byOperator`, `byBareName`, `families`, `explicitBacking`, `scope`; the position operators' names by class: `positionFamilies`, `byPositionOperator`, `positionNames` (operator → class → SQL name) | | `meos-idl.json#/functions[].{network,wire,api}` | per-function projectability + decode/encode/array/out-param wire model | | `meos-idl.json#/temporalTypes` | per `Temporal`: its `base`, its `bbox`, the `mfjson` type token `asMFJSON` writes, and the `number` / `spatial` / `linear` classes | | `meos-idl.json#/typeRelations/byBase` | each base type's `set`, `span`, `spanset` and its `temporal` types, the last a list since a base carries several | @@ -31,7 +31,9 @@ python tools/portable_parity.py # -> output/meos-portable-parity.json (bare-nam `portableAliases.byOperator` (drop type-qualified forms like `spanOverlaps`). Done = every operator in `byOperator` is callable by its bare name, parity-checked with the same prefix logic as -`portable_parity.py`, **0 unbacked**. +`portable_parity.py`, **0 unbacked**. A position operator registers its +names by class from `portableAliases.positionNames` (`stboxLeft`, +`tboxBefore`), which are the `sqlfn` of its functions. **PyMEOS, JMEOS, MEOS.NET** — code-generate from `meos-idl.json` (`functions` + `portableAliases`) so every binding emits **identical** bare diff --git a/docs/portable-aliases.md b/docs/portable-aliases.md index 227096c..dc8960c 100644 --- a/docs/portable-aliases.md +++ b/docs/portable-aliases.md @@ -17,29 +17,56 @@ family, and is **type-agnostic** (it applies to every temporal type): | Family | Operator → bare name | |---|---| | Topology | `&&`→`overlaps` `@>`→`contains` `<@`→`contained` `-\|-`→`adjacent` | +| Temporal comparison | `#=`→`tEqual` `#<>`→`tNotEqual` `#<`→`tLessThan` `#<=`→`tLessEqual` `#>`→`tGreaterThan` `#>=`→`tGreaterEqual` | +| Ever comparison | `?=`→`eEqual` `?<>`→`eNotEqual` `?<`→`eLessThan` `?<=`→`eLessEqual` `?>`→`eGreaterThan` `?>=`→`eGreaterEqual` | +| Always comparison | `%=`→`aEqual` `%<>`→`aNotEqual` `%<`→`aLessThan` `%<=`→`aLessEqual` `%>`→`aGreaterThan` `%>=`→`aGreaterEqual` | +| Distance | `<->`→`tDistance` `\|=\|`→`nearestApproachDistance` | +| Same | `~=`→`same` | + +25 operator→bare-name pairs. Already-canonical (no aliasing needed): +`eIntersects`, `atTime`, restriction and spatial-relationship functions. + +## Position operators + +A position operator has one name per class of its operands instead: the +class followed by the position, a temporal operand taking the class of its +bounding box (MobilityDB#2717). `<<` is `setLeft`, `spanLeft`, +`spansetLeft`, `tboxLeft`, `stboxLeft` and `tpcboxLeft`; the Y and Z +positions exist for `stbox` and `tpcbox`. The mapping holds each operator +with its position, the stem those names and the MEOS C functions share +(`left_set_set`, `left_tspatial_tspatial`), under `positionFamilies`: + +| Family | Operator → position | +|---|---| | Time position | `<<#`→`before` `#>>`→`after` `&<#`→`overbefore` `#&>`→`overafter` | | Space X | `<<`→`left` `>>`→`right` `&<`→`overleft` `&>`→`overright` | | Space Y | `<<\|`→`below` `\|>>`→`above` `&<\|`→`overbelow` `\|&>`→`overabove` | | Space Z | `<>`→`back` `&`→`overback` | -| Temporal comparison | `#=`→`teq` `#<>`→`tne` `#<`→`tlt` `#<=`→`tle` `#>`→`tgt` `#>=`→`tge` | -| Distance | `<->`→`tdistance` `\|=\|`→`nearestApproachDistance` | -| Same | `~=`→`same` | -29 operator→bare-name pairs. Already-canonical (no aliasing needed): -`ever_*`/`always_*` (`?=`/`%=`), `eIntersects`, `atTime`, restriction and -spatial-relationship functions. +The catalog derives the names by class from the `@sqlfn` tags of the +functions whose `@sqlop` is the operator (`positionNames`, 64 names for the +16 operators). A name that is not the class followed by the position, or an +operator that no function carries, stops the pipeline: either means the +tags and the mapping disagree. ## In the catalog -`portableAliases` carries the verbatim `families`, plus derived bijective -lookups for codegen: +`portableAliases` carries the verbatim `families` and `positionFamilies`, +plus derived lookups for codegen: ```json "portableAliases": { "byOperator": { "&&": "overlaps", "#=": "tEqual", "~=": "same", ... }, "byBareName": { "overlaps": "&&", "tEqual": "#=", "same": "~=", ... }, "bareNames": ["aEqual", "aGreaterEqual", ..., "tLessEqual", "tLessThan", "tNotEqual"], - "count": 41, "provenance": {...}, "scope": {...}, "notes": [...] + "count": 25, + "byPositionOperator": { "<<": "left", "<<#": "before", ... }, + "positionNames": { + "<<": { "set": "setLeft", "span": "spanLeft", "spanset": "spansetLeft", + "stbox": "stboxLeft", "tbox": "tboxLeft", "tpcbox": "tpcboxLeft" }, + "<<|": { "stbox": "stboxBelow", "tpcbox": "tpcboxBelow" }, ... + }, + "provenance": {...}, "scope": {...}, "notes": [...] } ``` @@ -67,15 +94,14 @@ end state. `portable_parity.py` is the meos-api.json analogue of MobilityDB's `tools/portable_aliases/generate.py --check`: it cross-references every bare name against the catalog's function families (by the MEOS bare-name -prefix convention) and writes `output/meos-portable-parity.json`. - -Live result: **29 / 29 = 100%** — every operator's bare name is backed in -the catalog (28 directly by prefix; `nearestApproachDistance` via the -*verified* `explicitBacking` entry `nad` — the `nad_*` family, 35 -functions, confirmed present, not guessed). A bare name whose C family -prefix differs is resolved through `explicitBacking`, never false-flagged -as a gap and never silently dropped; `tests/test_portable_parity.py` -gates this (no bare name may be unclassified or regressed). +prefix convention), and every position operator against the family its +position prefixes (`left_*`, `before_*`) with its SQL names by class, and +writes `output/meos-portable-parity.json`. A bare name whose C family +prefix differs is resolved through `explicitBacking` (`nearestApproachDistance` +through `nad`, the `nad_*` family), and one that no entry resolves is +flagged `needs-explicit-backing` — never silently dropped; +`tests/test_portable_parity.py` gates this (no operator may be +unclassified). ## Provenance diff --git a/meta/portable-aliases.json b/meta/portable-aliases.json index c4f6293..8272344 100644 --- a/meta/portable-aliases.json +++ b/meta/portable-aliases.json @@ -1,5 +1,5 @@ { - "_comment": "Canonical portable bare-name dialect \u2014 the single codegen source of truth (RFC #920). Every binding/engine generates the SAME bare names from this mapping so users learn one reference and assume the rest. Operators are SQL operator symbols; bareName is the portable function name. The mapping is type-agnostic: it applies to EVERY temporal type family.", + "_comment": "Canonical portable bare-name dialect \u2014 the single codegen source of truth (RFC #920). Every binding/engine generates the SAME bare names from this mapping so users learn one reference and assume the rest. Operators are SQL operator symbols; bareName is the portable function name. The mapping is type-agnostic: it applies to EVERY temporal type family. A position operator (`positionFamilies`) has instead one name per class of its operands.", "provenance": { "discussion": "MobilityDB#861", "rfc": "MobilityDB RFC #920 (doc/rfc/sql-portability/README.md, branch rfc/sql-portability)", @@ -25,78 +25,6 @@ "bareName": "adjacent" } ], - "timePosition": [ - { - "operator": "<<#", - "bareName": "before" - }, - { - "operator": "#>>", - "bareName": "after" - }, - { - "operator": "&<#", - "bareName": "overbefore" - }, - { - "operator": "#&>", - "bareName": "overafter" - } - ], - "spaceX": [ - { - "operator": "<<", - "bareName": "left" - }, - { - "operator": ">>", - "bareName": "right" - }, - { - "operator": "&<", - "bareName": "overleft" - }, - { - "operator": "&>", - "bareName": "overright" - } - ], - "spaceY": [ - { - "operator": "<<|", - "bareName": "below" - }, - { - "operator": "|>>", - "bareName": "above" - }, - { - "operator": "&<|", - "bareName": "overbelow" - }, - { - "operator": "|&>", - "bareName": "overabove" - } - ], - "spaceZ": [ - { - "operator": "<>", - "bareName": "back" - }, - { - "operator": "&", - "bareName": "overback" - } - ], "temporalComparison": [ { "operator": "#=", "bareName": "tEqual" }, { "operator": "#<>", "bareName": "tNotEqual" }, @@ -138,6 +66,33 @@ } ] }, + "_positionFamiliesComment": "A position operator has one SQL name per class of its operands, the class followed by the position: `<<` is setLeft, spanLeft, spansetLeft, tboxLeft, stboxLeft and tpcboxLeft (MobilityDB#2717). A temporal operand takes the class of its bounding box. `position` is the stem those names and the MEOS C functions share (left_set_set, left_tspatial_tspatial). The catalog derives the names by class from the @sqlfn tags of the functions whose @sqlop is the operator (`portableAliases.positionNames`).", + "positionFamilies": { + "timePosition": [ + { "operator": "<<#", "position": "before" }, + { "operator": "#>>", "position": "after" }, + { "operator": "&<#", "position": "overbefore" }, + { "operator": "#&>", "position": "overafter" } + ], + "spaceX": [ + { "operator": "<<", "position": "left" }, + { "operator": ">>", "position": "right" }, + { "operator": "&<", "position": "overleft" }, + { "operator": "&>", "position": "overright" } + ], + "spaceY": [ + { "operator": "<<|", "position": "below" }, + { "operator": "|>>", "position": "above" }, + { "operator": "&<|", "position": "overbelow" }, + { "operator": "|&>", "position": "overabove" } + ], + "spaceZ": [ + { "operator": "<>", "position": "back" }, + { "operator": "&", "position": "overback" } + ] + }, "alreadyCanonical": [ { "kind": "functions", @@ -170,6 +125,6 @@ "notes": [ "Generate aliases by reusing each operator's own backing C function (equivalence by construction), never by reimplementing; mirror MobilityDB tools/portable_aliases/generate.py + its 100%-coverage audit.", "User-facing API uses the full name `trgeometry`; internal functions keep the `trgeo_` prefix \u2014 do NOT normalize the internal prefix.", - "Goal: 100% parity ecosystem-wide \u2014 every operator has its bare name on every engine, no gaps, no headline exclusions." + "Goal: 100% parity ecosystem-wide \u2014 every operator has its portable name (its bare name, or its name by class for a position operator) on every engine, no gaps, no headline exclusions." ] } diff --git a/meta/portable-aliases.schema.json b/meta/portable-aliases.schema.json index 8437505..54500db 100644 --- a/meta/portable-aliases.schema.json +++ b/meta/portable-aliases.schema.json @@ -5,9 +5,10 @@ "description": "Schema for `meta/portable-aliases.json` (RFC #920). Catches shape regressions earlier than the unit tests; validated as a test step in `tests/test_portable.py`.", "type": "object", "additionalProperties": true, - "required": ["provenance", "families", "alreadyCanonical", "explicitBacking", "scope", "notes"], + "required": ["provenance", "families", "positionFamilies", "alreadyCanonical", "explicitBacking", "scope", "notes"], "properties": { "_comment": {"type": "string"}, + "_positionFamiliesComment": {"type": "string"}, "_explicitBackingComment": {"type": "string"}, "provenance": { @@ -40,6 +41,25 @@ } }, + "positionFamilies": { + "type": "object", + "description": "Position operators, whose SQL names are the class of their operands followed by the position (setLeft … stboxLeft). `position` is the stem the names and the MEOS C functions share; the catalog derives the names by class.", + "minProperties": 1, + "additionalProperties": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["operator", "position"], + "properties": { + "operator": {"type": "string", "minLength": 1, "maxLength": 8}, + "position": {"type": "string", "pattern": "^[a-z]+$"} + } + } + } + }, + "alreadyCanonical": { "type": "array", "description": "Entries already aligned with the canonical naming — no new alias needed. Discriminated by `kind` so consumers don't have to guess the shape.", diff --git a/parser/portable.py b/parser/portable.py index 2ca17f4..c794f28 100644 --- a/parser/portable.py +++ b/parser/portable.py @@ -3,7 +3,9 @@ `meta/portable-aliases.json` is the curated, authoritative operator → bare-name mapping (RFC #920; native in MobilityDB via PR #1075). Folding it into the catalog means every binding/engine generates the *identical* bare -names, so a user learns one reference and assumes the rest. +names, so a user learns one reference and assumes the rest. A position +operator has one name per class instead (setLeft … stboxLeft), derived from +the catalog's @sqlfn/@sqlop tags by ``attach_position_names``. This is curated canonical data, not a heuristic — it is preserved verbatim and only *derived* lookups are added (no guessing of C symbols: upstream @@ -30,9 +32,20 @@ def attach_portable_aliases(idl: dict, path: Path) -> dict: if len(by_operator) != len(pairs) or len(by_bare_name) != len(pairs): raise ValueError("portable-aliases: duplicate operator or bareName") + # A position operator has one SQL name per class of its operands (setLeft … + # stboxLeft), so it has no bare name: it is absent from the families above, and + # its operator and position are each unique. + positions = [p for fam in data["positionFamilies"].values() for p in fam] + by_position_operator = {p["operator"]: p["position"] for p in positions} + if (len(by_position_operator) != len(positions) + or len(set(by_position_operator.values())) != len(positions) + or set(by_position_operator) & set(by_operator)): + raise ValueError("portable-aliases: duplicate position operator or position") + idl["portableAliases"] = { "provenance": data["provenance"], "families": data["families"], + "positionFamilies": data["positionFamilies"], "alreadyCanonical": data["alreadyCanonical"], "explicitBacking": data.get("explicitBacking", {}), "scope": data["scope"], # cbuffer/npoint/pose/rgeo in scope @@ -41,10 +54,54 @@ def attach_portable_aliases(idl: dict, path: Path) -> dict: "byBareName": by_bare_name, # "overlaps" -> "&&" "bareNames": sorted(by_bare_name), "count": len(pairs), + "byPositionOperator": by_position_operator, # "<<" -> "left" } return idl +def attach_position_names(idl: dict) -> dict: + """Derive the SQL names of each position operator, by class. + + A position operator has one SQL name per class of its operands, the class + followed by the position: ``<<`` is setLeft, spanLeft, spansetLeft, tboxLeft, + stboxLeft and tpcboxLeft (MobilityDB#2717), a temporal operand taking the class of + its bounding box. The names are the ``@sqlfn`` tags of the functions whose + ``@sqlop`` is the operator, and the class is the name less its position. Adds + ``portableAliases.positionNames``: operator -> class -> SQL name. + + A name that does not end with its operator's position, or an operator that no + function carries, raises: either means the @sqlfn/@sqlop tags and the mapping + disagree, which a binding would otherwise inherit silently. + + MUST run AFTER ``attach_sqlfn_map`` (sqlfn/sqlop) AND ``attach_portable_aliases`` + (byPositionOperator). + """ + pa = idl.get("portableAliases") + if not pa: + return idl + by_position_operator = pa["byPositionOperator"] + names = {op: {} for op in by_position_operator} + for f in idl.get("functions", []): + op = (f.get("sqlop") or "").replace("\\", "") + sqlfn = f.get("sqlfn") or "" + if op not in by_position_operator or not sqlfn: + continue + pos = by_position_operator[op] + suffix = pos[0].upper() + pos[1:] + cls = sqlfn[:-len(suffix)] if sqlfn.endswith(suffix) else "" + if not cls: + raise ValueError(f"portable-aliases: {f['name']} has @sqlop {op} and " + f"@sqlfn {sqlfn}, which is not {suffix}") + names[op][cls] = sqlfn + missing = sorted(op for op, by_class in names.items() if not by_class) + if missing: + raise ValueError("portable-aliases: no function has @sqlop " + + " ".join(missing)) + pa["positionNames"] = {op: dict(sorted(by_class.items())) + for op, by_class in names.items()} + return idl + + def classify_backing_sqlfn(idl: dict) -> dict: """Mark the bounding-box topological BACKING ``@sqlfn`` tags. diff --git a/run.py b/run.py index d585a13..1b7a84e 100644 --- a/run.py +++ b/run.py @@ -5,7 +5,8 @@ from pathlib import Path from parser.parser import parse_all_headers, merge_meta -from parser.portable import attach_portable_aliases, classify_backing_sqlfn +from parser.portable import (attach_portable_aliases, attach_position_names, + classify_backing_sqlfn) from parser.covering import attach_temporal_covering from parser.typerecover import recover_collapsed_types, normalize_canonical from parser.header_types import reconcile @@ -265,6 +266,13 @@ def main(): nbo = sum(1 for f in idl.get("functions", []) if f.get("sqlfnBackingOnly")) print(f" Flagged {nbo} bbox-topological backing @sqlfn tag(s) " f"(sqlfnBackingOnly)", file=sys.stderr) + # The same map names each position operator once per class (setLeft … + # stboxLeft): derive those names from its @sqlfn/@sqlop tags. + idl = attach_position_names(idl) + pn = idl.get("portableAliases", {}).get("positionNames", {}) + print(f" Derived {sum(len(c) for c in pn.values())} position SQL " + f"name(s) by class for {len(pn)} position operator(s) " + f"(positionNames)", file=sys.stderr) # A PG wrapper can BIND a MEOS input to a fixed literal instead of exposing # it as a SQL argument (valueAtTimestamp hides `strict=true`). Capture those @@ -347,6 +355,7 @@ def main(): print(f" → {idl_path} written", file=sys.stderr) pa = idl.get("portableAliases", {}).get("count", 0) + npos = len(idl.get("portableAliases", {}).get("byPositionOperator", {})) cov = idl.get("temporalCovering", {}).get("count", 0) exposable = idl.get("enrichment", {}).get("exposableFunctions", 0) om = idl.get("objectModel", {}).get("summary", {}) @@ -356,6 +365,7 @@ def main(): f"{len(idl['enums'])} enums, " f"{len(idl.get('macros', []))} macros, " f"{pa} portable bare-name aliases, " + f"{npos} position operators, " f"{cov} temporal covering types", file=sys.stderr) if om: print(f" object model: {om['classesWithMethods']} classes, " diff --git a/tests/test_portable.py b/tests/test_portable.py index c7e74ab..5ab5685 100644 --- a/tests/test_portable.py +++ b/tests/test_portable.py @@ -7,21 +7,44 @@ import json import sys +import tempfile import unittest from pathlib import Path ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) -from parser.portable import attach_portable_aliases, classify_backing_sqlfn +from parser.portable import (attach_portable_aliases, attach_position_names, + classify_backing_sqlfn) MAP = ROOT / "meta" / "portable-aliases.json" SCHEMA = ROOT / "meta" / "portable-aliases.schema.json" _EXPECTED_FAMILY_SIZES = { - "topology": 4, "timePosition": 4, "spaceX": 4, "spaceY": 4, - "spaceZ": 4, "temporalComparison": 6, "everComparison": 6, + "topology": 4, "temporalComparison": 6, "everComparison": 6, "alwaysComparison": 6, "distance": 2, "same": 1, } +_EXPECTED_POSITION_SIZES = { + "timePosition": 4, "spaceX": 4, "spaceY": 4, "spaceZ": 4, +} + + +def _attach_map(data): + """Attach a mapping held in memory, as the pipeline attaches the file.""" + with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as f: + json.dump(data, f) + p = f.name + return attach_portable_aliases({}, Path(p)) + + +def _stbox_position_functions(skip=()): + """One stbox function per position operator, named as MobilityDB names it + (`left_stbox_stbox`, @sqlfn stboxLeft, @sqlop <<).""" + d = json.loads(MAP.read_text()) + return [{"name": f"{p['position']}_stbox_stbox", + "sqlfn": "stbox" + p["position"][0].upper() + p["position"][1:], + "sqlop": p["operator"]} + for fam in d["positionFamilies"].values() for p in fam + if p["operator"] not in skip] class MappingFileTests(unittest.TestCase): @@ -34,18 +57,36 @@ def test_families_complete_and_sized(self): for fam, n in _EXPECTED_FAMILY_SIZES.items(): self.assertEqual(len(self.d["families"][fam]), n, fam) + def test_position_families_complete_and_sized(self): + self.assertEqual(set(self.d["positionFamilies"]), + set(_EXPECTED_POSITION_SIZES)) + for fam, n in _EXPECTED_POSITION_SIZES.items(): + self.assertEqual(len(self.d["positionFamilies"][fam]), n, fam) + def test_known_mappings_verbatim(self): flat = {p["operator"]: p["bareName"] for fam in self.d["families"].values() for p in fam} for op, bn in [("&&", "overlaps"), ("@>", "contains"), - ("-|-", "adjacent"), ("<<#", "before"), - ("#&>", "overafter"), ("|&>", "overabove"), - ("/&>", "overback"), ("#=", "tEqual"), + ("-|-", "adjacent"), ("#=", "tEqual"), ("#<>", "tNotEqual"), ("?=", "eEqual"), ("%=", "aEqual"), ("|=|", "nearestApproachDistance"), ("~=", "same")]: self.assertEqual(flat[op], bn) - self.assertEqual(sum(_EXPECTED_FAMILY_SIZES.values()), 41) - self.assertEqual(len(flat), 41) + self.assertEqual(sum(_EXPECTED_FAMILY_SIZES.values()), 25) + self.assertEqual(len(flat), 25) + + def test_known_positions_verbatim(self): + flat = {p["operator"]: p["position"] + for fam in self.d["positionFamilies"].values() for p in fam} + for op, pos in [("<<#", "before"), ("#&>", "overafter"), + ("<<", "left"), ("&>", "overright"), + ("|&>", "overabove"), ("/&>", "overback")]: + self.assertEqual(flat[op], pos) + self.assertEqual(sum(_EXPECTED_POSITION_SIZES.values()), 16) + self.assertEqual(len(flat), 16) + # a position operator has names by class, never a bare name + bare_ops = {p["operator"] + for fam in self.d["families"].values() for p in fam} + self.assertFalse(set(flat) & bare_ops) def test_scope_correction_no_exclusion(self): # The corrected 100%-parity rule: these are IN scope, never deferred. @@ -105,16 +146,24 @@ class AttachTests(unittest.TestCase): def test_attach_and_derive(self): idl = attach_portable_aliases({"functions": []}, MAP) pa = idl["portableAliases"] - self.assertEqual(pa["count"], 41) + self.assertEqual(pa["count"], 25) self.assertEqual(pa["byOperator"]["&&"], "overlaps") self.assertEqual(pa["byBareName"]["overlaps"], "&&") self.assertEqual(pa["bareNames"], sorted(pa["byBareName"])) - # bijective: 41 distinct operators and 41 distinct bare names - self.assertEqual(len(pa["byOperator"]), 41) - self.assertEqual(len(pa["byBareName"]), 41) + # bijective: 25 distinct operators and 25 distinct bare names + self.assertEqual(len(pa["byOperator"]), 25) + self.assertEqual(len(pa["byBareName"]), 25) self.assertIn("cbuffer", pa["scope"]["inScopeTypeFamilies"]) self.assertEqual(pa["explicitBacking"], {"nearestApproachDistance": ["nad"]}) + # the 16 position operators, with their position, and no bare name + self.assertEqual(pa["byPositionOperator"]["<<"], "left") + self.assertEqual(pa["byPositionOperator"]["<<#"], "before") + self.assertEqual(len(pa["byPositionOperator"]), 16) + self.assertNotIn("<<", pa["byOperator"]) + self.assertNotIn("left", pa["byBareName"]) + self.assertEqual(set(pa["positionFamilies"]), + set(_EXPECTED_POSITION_SIZES)) def test_missing_file_is_noop(self): idl = attach_portable_aliases({"x": 1}, ROOT / "nope.json") @@ -127,7 +176,7 @@ def test_backing_sqlfn_classification(self): idl = attach_portable_aliases({"functions": [ {"name": "Same_stbox_stbox", "sqlfn": "same_bbox", "sqlop": "~="}, {"name": "Contains_tbox_tnumber", "sqlfn": "contains_bbox", "sqlop": "@>"}, - {"name": "Left_stbox_stbox", "sqlfn": "temporal_left", "sqlop": "<<"}, + {"name": "Left_stbox_stbox", "sqlfn": "stboxLeft", "sqlop": "<<"}, {"name": "Tpoint_trajectory", "sqlfn": "trajectory"}, ]}, MAP) idl = classify_backing_sqlfn(idl) @@ -137,7 +186,7 @@ def test_backing_sqlfn_classification(self): self.assertEqual(by["Same_stbox_stbox"]["publicSqlName"], "same") self.assertTrue(by["Contains_tbox_tnumber"]["sqlfnBackingOnly"]) self.assertEqual(by["Contains_tbox_tnumber"]["publicSqlName"], "contains") - # a positional op whose @sqlfn IS the deployed name (temporal_left) is untouched + # a position op whose @sqlfn IS the deployed name (stboxLeft) is untouched self.assertNotIn("sqlfnBackingOnly", by["Left_stbox_stbox"]) # a plain function with no operator is untouched self.assertNotIn("sqlfnBackingOnly", by["Tpoint_trajectory"]) @@ -148,18 +197,70 @@ def test_backing_sqlfn_noop_without_aliases(self): {"name": "X", "sqlfn": "same_bbox", "sqlop": "~="}]}) self.assertNotIn("sqlfnBackingOnly", idl["functions"][0]) + def test_position_names_by_class(self): + # Each position operator's names by class come from the @sqlfn of the + # functions whose @sqlop it is; the class is the name less the position. + fns = _stbox_position_functions() + [ + {"name": "left_set_set", "sqlfn": "setLeft", "sqlop": "<<"}, + {"name": "left_tnumber_tnumber", "sqlfn": "tboxLeft", "sqlop": "<<"}, + {"name": "left_tbox_tbox", "sqlfn": "tboxLeft", "sqlop": "<<"}, + {"name": "same_stbox_stbox", "sqlfn": "same_bbox", "sqlop": "~="}, + ] + idl = attach_portable_aliases({"functions": fns}, MAP) + pn = attach_position_names(idl)["portableAliases"]["positionNames"] + self.assertEqual(len(pn), 16) + self.assertEqual(pn["<<"], {"set": "setLeft", "stbox": "stboxLeft", + "tbox": "tboxLeft"}) + self.assertEqual(pn["<<#"], {"stbox": "stboxBefore"}) + self.assertEqual(pn["&Left: the tags and the + # mapping disagree, which must fail loudly rather than reach a binding. + for sqlfn in ("temporal_left", "Left"): + fns = _stbox_position_functions() + [ + {"name": "left_temporal_temporal", "sqlfn": sqlfn, "sqlop": "<<"}] + idl = attach_portable_aliases({"functions": fns}, MAP) + with self.assertRaises(ValueError): + attach_position_names(idl) + + def test_position_operator_without_function_raises(self): + idl = attach_portable_aliases( + {"functions": _stbox_position_functions(skip=("/&>",))}, MAP) + with self.assertRaisesRegex(ValueError, "/&>"): + attach_position_names(idl) + + def test_position_names_noop_without_aliases(self): + idl = attach_position_names({"functions": [ + {"name": "left_stbox_stbox", "sqlfn": "stboxLeft", "sqlop": "<<"}]}) + self.assertNotIn("portableAliases", idl) + def test_duplicate_detection(self): bad = {"families": {"a": [{"operator": "&&", "bareName": "x"}, {"operator": "@>", "bareName": "x"}]}, + "positionFamilies": {}, "provenance": {}, "alreadyCanonical": [], "scope": {}, "notes": []} - import tempfile - with tempfile.NamedTemporaryFile("w", suffix=".json", - delete=False) as f: - json.dump(bad, f) - p = f.name with self.assertRaises(ValueError): - attach_portable_aliases({}, Path(p)) + _attach_map(bad) + + def test_position_duplicate_detection(self): + base = {"families": {"a": [{"operator": "&&", "bareName": "overlaps"}]}, + "provenance": {}, "alreadyCanonical": [], "scope": {}, + "notes": []} + for positions in ( + # one operator both a bare name and a position + [{"operator": "&&", "position": "left"}], + # one operator two positions + [{"operator": "<<", "position": "left"}, + {"operator": "<<", "position": "right"}], + # one position two operators + [{"operator": "<<", "position": "left"}, + {"operator": ">>", "position": "left"}]): + with self.assertRaises(ValueError): + _attach_map(dict(base, positionFamilies={"p": positions})) if __name__ == "__main__": diff --git a/tests/test_portable_parity.py b/tests/test_portable_parity.py index e4ea7e3..a8201d3 100644 --- a/tests/test_portable_parity.py +++ b/tests/test_portable_parity.py @@ -33,7 +33,10 @@ def test_backed_vs_needs_explicit(self): "teq_temporal_temporal", # backs `teq` "same", # exact-name back "nad_tfloat_tfloat", # explicit backing - ]) # of nearestApproach… + # of nearestApproach… + "left_set_set", "left_tspatial_tspatial", # back `<<` + "overleft_stbox_stbox", # backs `&<`, not `<<` + ]) r = build_parity(cat) self.assertEqual(r["total"], 41) self.assertEqual(r["byBareName"]["overlaps"]["status"], "backed") @@ -48,15 +51,39 @@ def test_backed_vs_needs_explicit(self): self.assertNotIn("nearestApproachDistance", r["unbacked"]) self.assertEqual(r["byBareName"]["overlaps"]["family"], "topology") self.assertEqual(r["byBareName"]["tEqual"]["operator"], "#=") + # a position operator is backed by the MEOS family its position prefixes + left = r["byPosition"]["<<"] + self.assertEqual(left["status"], "backed") + self.assertEqual(left["position"], "left") + self.assertEqual(left["family"], "spaceX") + self.assertEqual(left["backedBy"], 2) + self.assertEqual(r["byPosition"]["&<"]["backedBy"], 1) + self.assertIn("<<#", r["unbackedPositions"]) + self.assertNotIn("<<", r["unbackedPositions"]) + self.assertNotIn("left", r["byBareName"]) + + def test_position_sql_names_reported(self): + # With the catalog's positionNames derived, each position operator + # reports its SQL names by class next to its backing. + cat = _catalog(["left_stbox_stbox"]) + cat["portableAliases"]["positionNames"] = { + "<<": {"set": "setLeft", "stbox": "stboxLeft"}} + r = build_parity(cat) + self.assertEqual(r["byPosition"]["<<"]["sqlNames"], + {"set": "setLeft", "stbox": "stboxLeft"}) + self.assertEqual(r["byPosition"][">>"]["sqlNames"], {}) def test_every_bare_name_classified(self): r = build_parity(_catalog([])) # nothing backs anything self.assertEqual(r["total"], 41) self.assertEqual(r["backed"], 0) - self.assertEqual(len(r["unbacked"]), 41) # all flagged, 0 dropped + self.assertEqual(r["needsExplicitBacking"], 41) + self.assertEqual(len(r["unbacked"]), 25) # all flagged, 0 dropped + self.assertEqual(len(r["unbackedPositions"]), 16) self.assertTrue(all(v["status"] in ("backed", "needs-explicit-backing") - for v in r["byBareName"].values())) + for v in list(r["byBareName"].values()) + + list(r["byPosition"].values()))) def test_requires_portable_aliases(self): with self.assertRaises(ValueError): diff --git a/tools/portable_parity.py b/tools/portable_parity.py index b8cbc04..c235e4c 100644 --- a/tools/portable_parity.py +++ b/tools/portable_parity.py @@ -11,6 +11,11 @@ # e.g. `nearestApproachDistance` ↔ `nad_*`): it is flagged # `needs-explicit-backing` so the cross-repo work can add an explicit # operator→C-family entry — an honest signal, never a fabricated verdict. +# +# A position operator has no bare name: its SQL names are one per class +# (`setLeft` … `stboxLeft`, the catalog's `positionNames`), and its MEOS C +# functions share its position as their prefix (`left_*`, `before_*`), which is +# what backs it. The report lists it under `byPosition`, keyed by operator. import json import sys @@ -47,10 +52,26 @@ def _matches(prefix): "sample": sorted(hits)[:3], "status": "backed" if hits else "needs-explicit-backing", } - backed = [b for b, v in by_bare.items() if v["status"] == "backed"] + position_names = pa.get("positionNames", {}) + by_position = {} + for fam, lst in sorted(pa.get("positionFamilies", {}).items()): + for p in lst: + hits = _matches(p["position"]) + by_position[p["operator"]] = { + "position": p["position"], "family": fam, + "via": "prefix" if hits else None, + "backedBy": len(hits), + "sample": sorted(hits)[:3], + "sqlNames": position_names.get(p["operator"], {}), + "status": "backed" if hits else "needs-explicit-backing", + } + backed = ([b for b, v in by_bare.items() if v["status"] == "backed"] + + [o for o, v in by_position.items() if v["status"] == "backed"]) unbacked = sorted(b for b, v in by_bare.items() if v["status"] == "needs-explicit-backing") - total = len(by_bare) + unbacked_positions = sorted(o for o, v in by_position.items() + if v["status"] == "needs-explicit-backing") + total = len(by_bare) + len(by_position) # Defensive cross-reference: every `alreadyCanonical` family entry has a # `pattern` like `"ever_*"` that must match at least one catalog function. @@ -78,11 +99,13 @@ def _matches(prefix): return { "total": total, "backed": len(backed), - "needsExplicitBacking": len(unbacked), + "needsExplicitBacking": len(unbacked) + len(unbacked_positions), "parityPct": round(len(backed) * 100 / total, 1) if total else 0, "canonicalDrift": canonical_drift, # empty list = no drift detected "unbacked": unbacked, # the precise cross-repo worklist + "unbackedPositions": unbacked_positions, "byBareName": by_bare, + "byPosition": by_position, } @@ -92,7 +115,7 @@ def main() -> None: rep = build_parity(json.loads(IN_PATH.read_text())) OUT_PATH.parent.mkdir(parents=True, exist_ok=True) OUT_PATH.write_text(json.dumps(rep, indent=2)) - print(f"[portable-parity] {rep['backed']}/{rep['total']} bare names " + print(f"[portable-parity] {rep['backed']}/{rep['total']} operators " f"backed in the catalog ({rep['parityPct']}%); " f"{rep['needsExplicitBacking']} need an explicit backing entry " f"→ {OUT_PATH}", file=sys.stderr) @@ -100,6 +123,10 @@ def main() -> None: v = rep["byBareName"][b] print(f" needs-explicit-backing: {b!r} ({v['operator']}, " f"{v['family']})", file=sys.stderr) + for o in rep["unbackedPositions"]: + v = rep["byPosition"][o] + print(f" needs-explicit-backing: {o!r} ({v['position']}, " + f"{v['family']})", file=sys.stderr) for drift in rep["canonicalDrift"]: print(f" canonical-drift: family={drift['family']!r} pattern={drift['pattern']!r} — " f"{drift['issue']}", file=sys.stderr)