diff --git a/.github/workflows/portable-parity.yml b/.github/workflows/portable-parity.yml
new file mode 100644
index 0000000..56f263a
--- /dev/null
+++ b/.github/workflows/portable-parity.yml
@@ -0,0 +1,34 @@
+name: portable-aliases parity
+
+# Gates the portable bare-name dialect (RFC #920; MEOS-API cross-repo
+# handoff PR #9): the generated MEOS.NET symbol set must remain a superset
+# of portableAliases.bareNames — 29/29, 0 unbacked, all six in-scope
+# type families — so a regenerated binding can never silently drop a
+# canonical bare name.
+
+on:
+ push:
+ pull_request:
+
+jobs:
+ parity:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.11'
+
+ - name: Parity gate (script — generated symbol set ⊇ bareNames)
+ run: python3 tools/portable_parity.py --check
+
+ - uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '8.0.x'
+
+ - name: Parity gate (.NET test — language-independent mirror)
+ run: >
+ dotnet test MEOS.NET.Tests/MEOS.NET.Tests.csproj
+ --filter "FullyQualifiedName~PortableAliasParityTests"
+ -c Release
diff --git a/.gitignore b/.gitignore
index c5b6d2e..fb21318 100644
--- a/.gitignore
+++ b/.gitignore
@@ -402,4 +402,8 @@ ASALocalRun/
.mfractor/
# Local History for Visual Studio
-.localhistory/
\ No newline at end of file
+.localhistory/
+
+# Generated portable-aliases parity report (regenerated by the CI gate and
+# the .NET parity test; the proof is the gate, not a checked-in artifact)
+tools/portable-parity.report.json
\ No newline at end of file
diff --git a/MEOS.NET.Tests/PortableAliasParityTests.cs b/MEOS.NET.Tests/PortableAliasParityTests.cs
new file mode 100644
index 0000000..a7e4953
--- /dev/null
+++ b/MEOS.NET.Tests/PortableAliasParityTests.cs
@@ -0,0 +1,136 @@
+using System.Text.Json;
+using System.Text.RegularExpressions;
+
+namespace MEOS.NET.Tests
+{
+ ///
+ /// Portable bare-name parity gate (RFC #920; MEOS-API cross-repo handoff
+ /// PR #9). A binding is done when its generated symbol set ⊇
+ /// portableAliases.bareNames, verified with the same prefix logic as
+ /// MEOS-API portable_parity.py — a bare name is backed iff some emitted
+ /// MEOS symbol == bareName or startsWith(bareName + "_"),
+ /// falling back to the verified explicitBacking prefixes
+ /// (nearestApproachDistance ← the nad_* family). 0 unbacked,
+ /// no per-binding exceptions, across all six in-scope type families
+ /// (cbuffer/npoint/pose/rgeo are full user-facing types — never excluded
+ /// from the parity headline). This is the C# mirror of
+ /// tools/portable_parity.py, so the verdict is identical and
+ /// language-independent.
+ ///
+ [TestClass]
+ public class PortableAliasParityTests
+ {
+ private static readonly string[] InScopeFamilies =
+ { "temporal", "geo", "cbuffer", "npoint", "pose", "rgeo" };
+
+ private static string RepoRoot()
+ {
+ var dir = new DirectoryInfo(AppContext.BaseDirectory);
+ while (dir is not null &&
+ !File.Exists(Path.Combine(dir.FullName, "MEOS.NET.sln")))
+ dir = dir.Parent;
+ Assert.IsNotNull(dir, "Could not locate repo root (MEOS.NET.sln).");
+ return dir!.FullName;
+ }
+
+ private static HashSet GeneratedSymbols(string repo)
+ {
+ var cs = Path.Combine(repo, "MEOS.NET", "Internal",
+ "MEOSExternalFunctions.cs");
+ Assert.IsTrue(File.Exists(cs),
+ $"Generated bindings missing: {cs}");
+ var rx = new Regex(@"public\s+static\s+partial\s+\S+\s+([A-Za-z_]\w*)\s*\(");
+ return rx.Matches(File.ReadAllText(cs))
+ .Select(m => m.Groups[1].Value)
+ .ToHashSet(StringComparer.Ordinal);
+ }
+
+ private static (List<(string op, string bare, string fam)> pairs,
+ Dictionary explicitBacking)
+ Contract(string repo)
+ {
+ var json = Path.Combine(repo, "tools", "portable-aliases.json");
+ Assert.IsTrue(File.Exists(json),
+ $"Vendored portable-aliases SoT missing: {json}");
+ using var doc = JsonDocument.Parse(File.ReadAllText(json));
+ var root = doc.RootElement;
+
+ var pairs = new List<(string, string, string)>();
+ foreach (var fam in root.GetProperty("families").EnumerateObject())
+ foreach (var e in fam.Value.EnumerateArray())
+ pairs.Add((e.GetProperty("operator").GetString()!,
+ e.GetProperty("bareName").GetString()!,
+ fam.Name));
+
+ var explicitBacking = new Dictionary();
+ if (root.TryGetProperty("explicitBacking", out var eb))
+ foreach (var p in eb.EnumerateObject())
+ explicitBacking[p.Name] =
+ p.Value.EnumerateArray()
+ .Select(x => x.GetString()!).ToArray();
+
+ return (pairs, explicitBacking);
+ }
+
+ private static List Backing(string bare,
+ HashSet symbols, Dictionary explicitBacking)
+ {
+ bool M(string s, string p) => s == p || s.StartsWith(p + "_",
+ StringComparison.Ordinal);
+ var hits = symbols.Where(s => M(s, bare)).ToList();
+ if (hits.Count == 0 &&
+ explicitBacking.TryGetValue(bare, out var prefixes))
+ foreach (var pref in prefixes)
+ hits.AddRange(symbols.Where(s => M(s, pref)));
+ return hits;
+ }
+
+ private static string FamilyOf(string name)
+ {
+ var n = name.ToLowerInvariant();
+ if (n.Contains("rgeo")) return "rgeo";
+ if (n.Contains("cbuffer")) return "cbuffer";
+ if (n.Contains("npoint")) return "npoint";
+ if (n.Contains("pose")) return "pose";
+ if (n.Contains("geo") || n.Contains("geom") || n.Contains("geog")
+ || n.Contains("point") || n.Contains("spatial")) return "geo";
+ return "temporal";
+ }
+
+ [TestMethod]
+ public void GeneratedApi_Superset_Of_PortableBareNames_ZeroUnbacked()
+ {
+ var repo = RepoRoot();
+ var symbols = GeneratedSymbols(repo);
+ var (pairs, explicitBacking) = Contract(repo);
+
+ Assert.AreEqual(29, pairs.Count,
+ "The canonical contract must carry exactly 29 operator→bareName pairs.");
+
+ var unbacked = new List();
+ var famTotals = InScopeFamilies.ToDictionary(f => f, _ => 0);
+ foreach (var (op, bare, fam) in pairs)
+ {
+ var hits = Backing(bare, symbols, explicitBacking);
+ if (hits.Count == 0) { unbacked.Add($"{bare} ({op}, {fam})"); continue; }
+ foreach (var h in hits)
+ {
+ var k = FamilyOf(h);
+ if (famTotals.ContainsKey(k)) famTotals[k]++;
+ }
+ }
+
+ Assert.AreEqual(0, unbacked.Count,
+ "Unbacked canonical bare names (generated symbol set must be a "
+ + "superset, 0 unbacked): " + string.Join(", ", unbacked));
+
+ var missing = InScopeFamilies.Where(f => famTotals[f] == 0).ToList();
+ Assert.AreEqual(0, missing.Count,
+ "In-scope user-facing families absent from the generated binding "
+ + "(cbuffer/npoint/pose/rgeo are never excluded from the parity "
+ + "headline): " + string.Join(", ", missing)
+ + ". Coverage: "
+ + string.Join(", ", famTotals.Select(kv => $"{kv.Key}={kv.Value}")));
+ }
+ }
+}
diff --git a/tools/README.md b/tools/README.md
index 523e400..4bee94d 100644
--- a/tools/README.md
+++ b/tools/README.md
@@ -59,3 +59,45 @@ hand-written wrappers in `MEOS.NET/Types/` need updates for:
Running `dotnet build MEOS.NET/MEOS.NET.csproj` against the new
bindings surfaces every adaptation point as a compiler error — work
through the list, no hidden runtime breakage.
+
+## Portable bare-name dialect (RFC #920)
+
+The MobilityDB ecosystem defines a canonical, type-agnostic
+operator → bare-name dialect so one query runs identically on every
+engine and binding (a user learns one reference and assumes the rest).
+The contract is **29 operator → bareName pairs** across eight families
+(topology, time-position, space X/Y/Z, temporal-comparison, distance,
+same), the single source of truth being MEOS-API
+`meta/portable-aliases.json` (discussion MobilityDB#861 · RFC #920 ·
+native MobilityDB#1075 · manual MobilityDB#1078).
+
+`tools/portable-aliases.json` is that contract, vendored **byte-identical**
+so this binding is self-contained until MEOS-API folds `portableAliases`
+into `meos-idl.json` (after which the catalog copy is preferred
+automatically).
+
+A bare name is **backed** when the generated symbol set contains a MEOS
+function whose name `== bareName` or `startsWith(bareName + "_")`, with
+`nearestApproachDistance` backed by the verified `nad_*` family
+(`explicitBacking`). MEOS C already names every operator's backing
+function this way, so the generated bindings expose the dialect by
+construction — each portable name reuses the operator's own backing
+function, never a reimplementation. No type-qualified or per-binding
+forms are introduced.
+
+### Verifying parity
+
+```
+python3 tools/portable_parity.py --check
+```
+
+Exits non-zero unless **29/29 bare names are backed, 0 unbacked**, across
+all six in-scope user-facing type families — `temporal`, `geo`,
+`cbuffer`, `npoint`, `pose`, `rgeo` (`cbuffer`/`npoint`/`pose`/`rgeo` are
+full temporal types and are never excluded from the parity headline).
+The same check runs as the `PortableAliasParityTests` MSTest case and in
+the `portable-aliases parity` CI workflow.
+
+Note: the MEOS 1.4 surface is required for full six-family coverage —
+the MEOS 1.3 catalog does not expose `cbuffer`/`pose`/`rgeo` operator
+functions.
diff --git a/tools/portable-aliases.json b/tools/portable-aliases.json
new file mode 100644
index 0000000..1cabac1
--- /dev/null
+++ b/tools/portable-aliases.json
@@ -0,0 +1,60 @@
+{
+ "_comment": "Canonical portable bare-name dialect — 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.",
+ "provenance": {
+ "discussion": "MobilityDB#861",
+ "rfc": "MobilityDB RFC #920 (doc/rfc/sql-portability/README.md, branch rfc/sql-portability)",
+ "nativePR": "MobilityDB#1075 (1303 operator-overload aliases, each reusing the operator's own C symbol — identical by construction; CI-gated by tools/portable_aliases/generate.py --check)",
+ "manualChapter": "MobilityDB#1078"
+ },
+ "families": {
+ "topology": [{"operator": "&&", "bareName": "overlaps"},
+ {"operator": "@>", "bareName": "contains"},
+ {"operator": "<@", "bareName": "contained"},
+ {"operator": "-|-", "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": "front"},
+ {"operator": "/>>", "bareName": "back"},
+ {"operator": "&", "bareName": "overfront"},
+ {"operator": "/&>", "bareName": "overback"}],
+ "temporalComparison": [{"operator": "#=", "bareName": "teq"},
+ {"operator": "#<>", "bareName": "tne"},
+ {"operator": "#<", "bareName": "tlt"},
+ {"operator": "#<=", "bareName": "tle"},
+ {"operator": "#>", "bareName": "tgt"},
+ {"operator": "#>=", "bareName": "tge"}],
+ "distance": [{"operator": "<->", "bareName": "tdistance"},
+ {"operator": "|=|", "bareName": "nearestApproachDistance"}],
+ "same": [{"operator": "~=", "bareName": "same"}]
+ },
+ "alreadyCanonical": [
+ {"family": "ever", "operators": ["?="], "pattern": "ever_*"},
+ {"family": "always", "operators": ["%="], "pattern": "always_*"},
+ {"functions": ["eIntersects", "atTime", "restriction functions",
+ "spatial-relationship functions"]}
+ ],
+ "_explicitBackingComment": "Bare names whose MEOS C family prefix differs from the bare name itself. Verified against the catalog (not guessed): `nearestApproachDistance` is backed by the `nad_*` family (35 functions). Lets the parity audit resolve 100% honestly instead of false-flagging a real, present family.",
+ "explicitBacking": {
+ "nearestApproachDistance": ["nad"]
+ },
+ "scope": {
+ "inScopeTypeFamilies": ["temporal", "geo", "cbuffer", "npoint", "pose",
+ "rgeo"],
+ "note": "cbuffer / npoint / pose / rgeo are FULL user-facing temporal types and ARE in scope — covered like every other type. PR #1075 already aliases all six families (1303 aliases). They must NOT be excluded from any parity headline; an upstream/audit note that 'defers' or 'jointly excludes' them is a known error being corrected — where another engine defers them, that is incomplete work to close (a gap with a plan), never an accepted exclusion."
+ },
+ "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 — do NOT normalize the internal prefix.",
+ "Goal: 100% parity ecosystem-wide — every operator has its bare name on every engine, no gaps, no headline exclusions."
+ ]
+}
diff --git a/tools/portable_parity.py b/tools/portable_parity.py
new file mode 100644
index 0000000..354a01e
--- /dev/null
+++ b/tools/portable_parity.py
@@ -0,0 +1,177 @@
+#!/usr/bin/env python3
+"""Portable bare-name parity gate for MEOS.NET.
+
+The MEOS.NET analogue of MEOS-API's portable_parity.py and MobilityDB's
+`tools/portable_aliases/generate.py --check`. Per the cross-repo handoff
+(MEOS-API PR #9): a binding is done when its **generated symbol set ⊇
+portableAliases.bareNames**, verified with the *same prefix logic* as
+MEOS-API portable_parity.py, **0 unbacked**, no per-binding exceptions,
+across all six in-scope type families.
+
+"Generated symbol set" = the P/Invoke declarations the codegen emits into
+MEOS.NET/Internal/MEOSExternalFunctions.cs (the MEOS C function names the
+binding actually exposes). A bare name is *backed* iff some emitted symbol
+`== bareName` or `startswith(bareName + "_")`, falling back to the verified
+`explicitBacking` prefixes (e.g. `nearestApproachDistance` <- the `nad_*`
+family). This is the operator's *own* backing function reused by
+construction — never reimplemented.
+
+The portable-aliases contract is read from the catalog's folded-in
+`portableAliases` when an --idl is given and carries it; otherwise from the
+vendored, byte-identical SoT copy tools/portable-aliases.json (so the gate
+is self-contained while upstream MEOS-API #8 is in flight).
+
+ python3 tools/portable_parity.py # write report
+ python3 tools/portable_parity.py --check # exit non-zero on any gap
+
+Writes tools/portable-parity.report.json.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import re
+import sys
+from pathlib import Path
+
+REPO = Path(__file__).resolve().parent.parent
+GENERATED = REPO / "MEOS.NET" / "Internal" / "MEOSExternalFunctions.cs"
+VENDORED = Path(__file__).resolve().parent / "portable-aliases.json"
+REPORT = Path(__file__).resolve().parent / "portable-parity.report.json"
+
+# Full user-facing temporal type families — cbuffer/npoint/pose/rgeo are NOT
+# internals and must never be excluded from the parity headline. Precedence
+# keeps the broad geo/temporal buckets from swallowing them.
+IN_SCOPE_FAMILIES = ["temporal", "geo", "cbuffer", "npoint", "pose", "rgeo"]
+
+_DECL_RE = re.compile(r"public\s+static\s+partial\s+\S+\s+([A-Za-z_]\w*)\s*\(")
+
+
+def generated_symbols(cs_path: Path) -> list[str]:
+ """The MEOS C function names the binding exposes (one per P/Invoke decl)."""
+ return sorted(set(_DECL_RE.findall(cs_path.read_text())))
+
+
+def load_portable_aliases(idl_path: str | None) -> dict:
+ """Prefer the catalog's folded-in portableAliases; else the vendored SoT."""
+ if idl_path:
+ idl = json.loads(Path(idl_path).read_text())
+ pa = idl.get("portableAliases")
+ if pa and pa.get("families"):
+ return pa
+ return json.loads(VENDORED.read_text())
+
+
+def family_of(name: str) -> str:
+ n = name.lower()
+ if "rgeo" in n:
+ return "rgeo"
+ if "cbuffer" in n:
+ return "cbuffer"
+ if "npoint" in n:
+ return "npoint"
+ if "pose" in n:
+ return "pose"
+ if any(t in n for t in ("geo", "geom", "geog", "point", "spatial")):
+ return "geo"
+ return "temporal"
+
+
+def build_parity(symbols: list[str], pa: dict) -> dict:
+ fam_of = {p["bareName"]: (fam, p["operator"])
+ for fam, lst in pa["families"].items() for p in lst}
+ explicit = pa.get("explicitBacking", {})
+
+ def matches(prefix: str) -> list[str]:
+ return [s for s in symbols
+ if s == prefix or s.startswith(prefix + "_")]
+
+ by_bare: dict[str, dict] = {}
+ fam_totals: dict[str, int] = {f: 0 for f in IN_SCOPE_FAMILIES}
+ for bare, (fam, op) in sorted(fam_of.items()):
+ hits, via = matches(bare), "prefix"
+ if not hits:
+ for pref in explicit.get(bare, []):
+ hits += matches(pref)
+ via = ("explicit:" + ",".join(explicit.get(bare, []))
+ if hits else None)
+ hist: dict[str, int] = {}
+ for h in hits:
+ k = family_of(h)
+ hist[k] = hist.get(k, 0) + 1
+ fam_totals[k] = fam_totals.get(k, 0) + 1
+ by_bare[bare] = {
+ "operator": op, "family": fam, "via": via,
+ "backedBy": len(hits), "sample": sorted(hits)[:3],
+ "familyCoverage": hist,
+ "status": "backed" if hits else "needs-explicit-backing",
+ }
+
+ backed = [b for b, v in by_bare.items() if v["status"] == "backed"]
+ unbacked = sorted(b for b, v in by_bare.items()
+ if v["status"] == "needs-explicit-backing")
+ missing_fams = [f for f in IN_SCOPE_FAMILIES if fam_totals.get(f, 0) == 0]
+ total = len(by_bare)
+ return {
+ "generatedSymbols": len(symbols),
+ "total": total,
+ "backed": len(backed),
+ "needsExplicitBacking": len(unbacked),
+ "parityPct": round(len(backed) * 100 / total, 1) if total else 0,
+ "unbacked": unbacked,
+ "familyCoverage": fam_totals,
+ "missingFamilies": missing_fams,
+ "byBareName": by_bare,
+ "provenance": pa.get("provenance", {}),
+ "scope": pa.get("scope", {}),
+ }
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser(description=__doc__)
+ ap.add_argument("--idl", metavar="meos-idl.json", default=None,
+ help="catalog to read portableAliases from "
+ "(default: vendored tools/portable-aliases.json)")
+ ap.add_argument("--generated", default=str(GENERATED),
+ help="generated P/Invoke file to audit")
+ ap.add_argument("--check", action="store_true",
+ help="exit non-zero if any bare name is unbacked or any "
+ "in-scope family is uncovered (CI gate)")
+ args = ap.parse_args()
+
+ symbols = generated_symbols(Path(args.generated))
+ pa = load_portable_aliases(args.idl)
+ rep = build_parity(symbols, pa)
+ REPORT.write_text(json.dumps(rep, indent=2) + "\n")
+
+ src = ("idl.portableAliases" if args.idl
+ and json.loads(Path(args.idl).read_text())
+ .get("portableAliases", {}).get("families")
+ else "vendored tools/portable-aliases.json")
+ print(f"[portable-parity] {rep['backed']}/{rep['total']} bare names "
+ f"backed in the generated .NET symbol set ({rep['parityPct']}%); "
+ f"{rep['needsExplicitBacking']} unbacked [contract: {src}]",
+ file=sys.stderr)
+ print(f"[portable-parity] six-family coverage "
+ f"{rep['familyCoverage']} -> {REPORT}", file=sys.stderr)
+ for b in rep["unbacked"]:
+ v = rep["byBareName"][b]
+ print(f" needs-explicit-backing: {b!r} ({v['operator']}, "
+ f"{v['family']})", file=sys.stderr)
+
+ fail = bool(rep["unbacked"] or rep["missingFamilies"])
+ if args.check:
+ if rep["missingFamilies"]:
+ print(" uncovered in-scope families: "
+ f"{rep['missingFamilies']}", file=sys.stderr)
+ verdict = ("FAIL" if fail else
+ f"PASS — {rep['backed']}/{rep['total']} = 100%, "
+ "0 unbacked, all six families covered")
+ print(f"CHECK: {verdict}", file=sys.stderr)
+ return 1 if fail else 0
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())