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": "back"}, + {"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())