From 33f36c40b61aa6b3f742b3fe1cde201f6b843e4f Mon Sep 17 00:00:00 2001 From: Esteban Zimanyi Date: Mon, 31 Aug 2026 15:59:45 +0200 Subject: [PATCH] Derive the lattice's type membership from the source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Which temporal types a class holds is MEOS's to state, so the parse reads it from `meos_catalog.c` at each run instead of the model file carrying a copy: a class naming a `predicate` takes the types that predicate admits, and a leaf takes the base type `MEOS_RELTYPE_CATALOG` gives the one type it models, through the same reader `attach_type_relations` uses. `Temporal` publishes 20 types and `TSpatial` 14, and a type MEOS adds reaches the published lattice with no edit here. The model file states neither, and a test holds that it does not — a copy is what drifts, so the fix is that there is none to drift. What it keeps is the design: the class tree, each class's prefixes, and the one type a leaf models, which is that class's identity rather than a restatement of MEOS. `objectModel.membership.status` reads `derived`, or `source-unavailable` where `meos_catalog.c` is not reachable, and the lattice then names no membership at all. A class naming no type would read the same as one MEOS has no type for, so the status is what tells them apart — the answer the error contract beside it already gives. A source that IS readable and disagrees with the model raises instead: a predicate the model names that MEOS does not declare, a predicate admitting nothing, a leaf modelling a type the relation catalog does not relate. `locate_catalog` is public for the same reason it is shared: two readers of one array are two things to keep in step. --- meta/object-model.json | 93 ++-------------------------- parser/object_model.py | 98 ++++++++++++++++++++++++++++- parser/typerelations.py | 4 +- tests/test_object_model.py | 119 +++++++++++++++++++++++++++++++++--- tests/test_typerelations.py | 6 +- 5 files changed, 218 insertions(+), 102 deletions(-) diff --git a/meta/object-model.json b/meta/object-model.json index 53fbe4f..0c03499 100644 --- a/meta/object-model.json +++ b/meta/object-model.json @@ -84,28 +84,6 @@ "prefixes": [ "temporal" ], - "temptypes": [ - "T_TBOOL", - "T_TTEXT", - "T_TJSONB", - "T_TINT", - "T_TBIGINT", - "T_TFLOAT", - "T_TGEOMPOINT", - "T_TGEOGPOINT", - "T_TGEOMETRY", - "T_TGEOGRAPHY", - "T_TCBUFFER", - "T_TNPOINT", - "T_TPOSE", - "T_TPOSECHAIN", - "T_TRGEOMETRY", - "T_TH3INDEX", - "T_TQUADBIN", - "T_TS2CELL", - "T_TPCPOINT", - "T_TPCPATCH" - ], "doc": "Superclass of every temporal type; temporal_* functions are late-bound over `subtype` and `temptype`." }, "TAlpha": { @@ -115,11 +93,6 @@ "prefixes": [ "talpha" ], - "temptypes": [ - "T_TBOOL", - "T_TTEXT", - "T_TJSONB" - ], "doc": "Non-numeric, non-spatial temporal types (step/discrete interpolation only). A real MEOS grouping (talpha_type) with no user-facing class name in PyMEOS — see corrections." }, "TBool": { @@ -131,8 +104,7 @@ ], "temptypes": [ "T_TBOOL" - ], - "cBaseType": "T_BOOL" + ] }, "TText": { "kind": "leaf", @@ -143,8 +115,7 @@ ], "temptypes": [ "T_TTEXT" - ], - "cBaseType": "T_TEXT" + ] }, "TJsonb": { "kind": "leaf", @@ -155,8 +126,7 @@ ], "temptypes": [ "T_TJSONB" - ], - "cBaseType": "T_JSONB" + ] }, "TNumber": { "kind": "abstract", @@ -165,11 +135,6 @@ "prefixes": [ "tnumber" ], - "temptypes": [ - "T_TINT", - "T_TBIGINT", - "T_TFLOAT" - ], "basePredicate": "tnumber_basetype", "doc": "Temporal numbers; supports linear interpolation." }, @@ -182,8 +147,7 @@ ], "temptypes": [ "T_TINT" - ], - "cBaseType": "T_INT4" + ] }, "TBigint": { "kind": "leaf", @@ -194,8 +158,7 @@ ], "temptypes": [ "T_TBIGINT" - ], - "cBaseType": "T_INT8" + ] }, "TFloat": { "kind": "leaf", @@ -206,8 +169,7 @@ ], "temptypes": [ "T_TFLOAT" - ], - "cBaseType": "T_FLOAT8" + ] }, "TSpatial": { "kind": "abstract", @@ -216,22 +178,6 @@ "prefixes": [ "tspatial" ], - "temptypes": [ - "T_TGEOMPOINT", - "T_TGEOGPOINT", - "T_TGEOMETRY", - "T_TGEOGRAPHY", - "T_TCBUFFER", - "T_TNPOINT", - "T_TPOSE", - "T_TPOSECHAIN", - "T_TRGEOMETRY", - "T_TH3INDEX", - "T_TQUADBIN", - "T_TS2CELL", - "T_TPCPOINT", - "T_TPCPATCH" - ], "doc": "Temporal types carrying an STBox spatial bounding box." }, "TGeo": { @@ -243,12 +189,6 @@ "tgeo" ], "userFacingName": "TGeo", - "temptypes": [ - "T_TGEOMETRY", - "T_TGEOGRAPHY", - "T_TGEOMPOINT", - "T_TGEOGPOINT" - ], "doc": "All PostGIS-derived spatiotemporal types (geometry/geography-based). Authoritative parent per MobilityDB manual Ch.7 Figure 7.1 (= the broad C predicate tgeo_type_all). NOTE: the narrower C predicate tgeo_type() and most tgeo_* functions reject points — class membership (manual) is broader than tgeo_* API applicability; see correction OM-M1." }, "TPoint": { @@ -259,10 +199,6 @@ "tpoint" ], "userFacingName": "TPoint", - "temptypes": [ - "T_TGEOMPOINT", - "T_TGEOGPOINT" - ], "doc": "Temporal points. API-level intermediate (C predicate tpoint_type + the tpoint_* method family); NOT drawn in the manual Figure 7.1 (a conceptual diagram) but required so the tpoint_* methods bind to a class — see correction OM-M6." }, "TGeomPoint": { @@ -276,7 +212,6 @@ "temptypes": [ "T_TGEOMPOINT" ], - "cBaseType": "T_GEOMETRY", "traits": [ "geometryBased" ] @@ -292,7 +227,6 @@ "temptypes": [ "T_TGEOGPOINT" ], - "cBaseType": "T_GEOGRAPHY", "traits": [ "geodetic" ] @@ -308,7 +242,6 @@ "temptypes": [ "T_TGEOMETRY" ], - "cBaseType": "T_GEOMETRY", "traits": [ "geometryBased" ] @@ -324,7 +257,6 @@ "temptypes": [ "T_TGEOGRAPHY" ], - "cBaseType": "T_GEOGRAPHY", "traits": [ "geodetic" ] @@ -340,7 +272,6 @@ "temptypes": [ "T_TCBUFFER" ], - "cBaseType": "T_CBUFFER", "conditional": "CBUFFER" }, "TNpoint": { @@ -354,7 +285,6 @@ "temptypes": [ "T_TNPOINT" ], - "cBaseType": "T_NPOINT", "conditional": "NPOINT" }, "TPose": { @@ -368,7 +298,6 @@ "temptypes": [ "T_TPOSE" ], - "cBaseType": "T_POSE", "conditional": "POSE" }, "TRGeometry": { @@ -384,7 +313,6 @@ "temptypes": [ "T_TRGEOMETRY" ], - "cBaseType": "T_POSE", "conditional": "RGEO", "note": "Base type is T_POSE, not a geometry — base != name (see corrections). User-facing API name is `trgeometry`; internal C functions keep the `trgeo_` prefix and must NOT be normalized." } @@ -393,19 +321,10 @@ "_comment": "Orthogonal boolean axes — NOT inheritance parents (modelling them as parents would create a diamond TGeomPoint<-{TPoint,TGeometryBased}). Tagged on leaves; each backed by a MEOS predicate, gated against source.", "geometryBased": { "predicate": "tgeometry_type", - "temptypes": [ - "T_TGEOMPOINT", - "T_TGEOMETRY" - ], "doc": "Cartesian (planar) base — geometry." }, "geodetic": { "predicate": "tgeodetic_type", - "temptypes": [ - "T_TGEOGPOINT", - "T_TGEOGRAPHY", - "T_TH3INDEX" - ], "doc": "Ellipsoidal base — geography; also the H3 DGGS cells (geodetic, SRID 4326)." } }, diff --git a/parser/object_model.py b/parser/object_model.py index ff1d3da..7bd5b2c 100644 --- a/parser/object_model.py +++ b/parser/object_model.py @@ -27,6 +27,8 @@ class is invented; a function with no prefix match is recorded honestly as import re from pathlib import Path +from parser.typerelations import locate_catalog, temptype_basetypes + def find_mobilitydb_src(headers_dir: Path | None = None) -> Path | None: """Resolve the MobilityDB C source root for the error scan / drift gate. @@ -298,6 +300,76 @@ def _scan_errors(src_root: Path, public: set) -> dict: return result +class MembershipUnavailable(RuntimeError): + """The source states a membership the lattice cannot use. + + Raised where ``meos_catalog.c`` IS readable and disagrees with the model — + a predicate the model names that MEOS does not declare, a predicate + admitting nothing, a leaf modelling a type the relation catalog does not + relate. Each is a real disagreement, never a missing file: an unreachable + source is reported as ``membership.status`` instead, the way the error + contract reports one. + """ + + +_PREDICATE_TEMPTYPE_RE = re.compile(r"\bT_T[A-Z0-9_]+\b") + +#: The tdoubleN types exist for temporal aggregation and are not part of the +#: published model, so a predicate admitting them contributes the rest. +_INTERNAL_TEMPTYPES = frozenset({"T_TDOUBLE2", "T_TDOUBLE3", "T_TDOUBLE4"}) + + +def _predicate_body(cat_src: str, name: str) -> str: + """The body of the ``name(MeosType ...)`` membership predicate.""" + m = re.search(r"\n" + re.escape(name) + r"\(MeosType \w+\)\s*", cat_src) + if not m: + raise MembershipUnavailable( + f"meos_catalog.c declares no `{name}` predicate — the lattice names " + "a membership oracle MEOS does not have") + i = cat_src.index("{", m.end()) + depth, j = 0, i + while j < len(cat_src): + depth += (cat_src[j] == "{") - (cat_src[j] == "}") + if depth == 0: + return cat_src[i:j + 1] + j += 1 + return cat_src[i:] + + +def predicate_temptypes(cat_src: str, name: str) -> list: + """The temporal types a membership predicate admits, in MeosType order.""" + seen, out = set(), [] + for t in _PREDICATE_TEMPTYPE_RE.findall(_predicate_body(cat_src, name)): + if t not in _INTERNAL_TEMPTYPES and t not in seen: + seen.add(t) + out.append(t) + if not out: + raise MembershipUnavailable(f"`{name}` admits no temporal type") + return out + + +def derive_membership(nodes: dict, cat_src: str, basetypes: dict) -> None: + """Fill each node's membership from the catalog, in place. + + A node naming a `predicate` takes the types that predicate admits; a leaf + takes the base type ``MEOS_RELTYPE_CATALOG`` gives the one type it models. + Neither is stated in the model file: both are MEOS's to say, and a copy of + either is a second source that goes stale the next time MEOS gains a type. + """ + for name, spec in nodes.items(): + pred = spec.get("predicate") + if pred: + spec["temptypes"] = predicate_temptypes(cat_src, pred) + temptypes = spec.get("temptypes") + if spec.get("kind") == "leaf" and temptypes: + temptype = temptypes[0] + if temptype not in basetypes: + raise MembershipUnavailable( + f"{name} models {temptype}, which the relation catalog " + "gives no base type") + spec["cBaseType"] = basetypes[temptype] + + def attach_object_model(idl: dict, path: Path, mobilitydb_src: Path | None = None) -> dict: """Attach ``idl["objectModel"]`` from the canonical lattice file.""" @@ -305,8 +377,29 @@ def attach_object_model(idl: dict, path: Path, return idl model = json.loads(Path(path).read_text()) - lat = _tree({k: v for k, v in model["lattice"].items() - if not k.startswith("_")}) + # The lattice's type membership is MEOS's to state, so it is read from + # meos_catalog.c at each parse rather than carried in the model file. A + # class the model names gains the types its predicate admits, and a leaf the + # base type the relation catalog gives it, so a type MEOS adds reaches the + # published model with no edit here. + catalog = locate_catalog(mobilitydb_src) + lattice_nodes = {k: v for k, v in model["lattice"].items() + if not k.startswith("_")} + trait_nodes = {k: v for k, v in model["traits"].items() + if not k.startswith("_")} + if catalog is not None: + cat_src = catalog.read_text(errors="ignore") + basetypes = temptype_basetypes(cat_src) + derive_membership(lattice_nodes, cat_src, basetypes) + derive_membership(trait_nodes, cat_src, basetypes) + membership = {"status": "derived", "source": str(catalog)} + else: + # Say so rather than publish an empty membership: a class naming no + # type would be indistinguishable from one MEOS has no type for. The + # error contract answers an unreachable source the same way. + membership = {"status": "source-unavailable", "source": None} + + lat = _tree(lattice_nodes) for fam in ("Box", "Collection"): _tree({k: v for k, v in model["companions"][fam]["nodes"].items() if not k.startswith("_")}) @@ -376,6 +469,7 @@ def attach_object_model(idl: dict, path: Path, idl["objectModel"] = { "provenance": model["provenance"], "axes": model["axes"], + "membership": membership, "lattice": lat, "traits": model["traits"], "companions": model["companions"], diff --git a/parser/typerelations.py b/parser/typerelations.py index c21c871..ffef114 100644 --- a/parser/typerelations.py +++ b/parser/typerelations.py @@ -65,7 +65,7 @@ def temptype_basetypes(cat_src: str) -> dict: if "temptype_basetype" in fields} -def _locate_catalog(src_root: Path | None) -> Path | None: +def locate_catalog(src_root: Path | None) -> Path | None: """The ``meos_catalog.c`` path from the resolved source root, or the ``MDB_SRC_ROOT`` checkout. The object-model resolver returns the ``meos/src`` directory when it can, but on the @@ -95,7 +95,7 @@ def attach_type_relations(idl: dict, src_root: Path | None) -> dict: registry: the consumers read the registry to resolve a concrete collection type, so an empty one silently degrades every one of them. """ - catalog = _locate_catalog(src_root) + catalog = locate_catalog(src_root) if catalog is None: return idl diff --git a/tests/test_object_model.py b/tests/test_object_model.py index 9de7b9c..fd6f582 100644 --- a/tests/test_object_model.py +++ b/tests/test_object_model.py @@ -20,6 +20,8 @@ sys.path.insert(0, str(ROOT)) from parser.typerelations import temptype_basetypes +from parser.object_model import (MembershipUnavailable, derive_membership, + predicate_temptypes) from parser.object_model import ( _scan_errors, attach_object_model, find_mobilitydb_src) @@ -54,7 +56,8 @@ def test_node_kinds_consistent(self): for n, s in self.lat.items(): self.assertIn(s["kind"], ("root", "abstract", "leaf")) if s["kind"] == "leaf": - self.assertIn("cBaseType", s, n) + # A leaf names the ONE type it models; its base type is the + # catalog's to give and is derived onto the attached model. self.assertEqual(len(s["temptypes"]), 1, n) if s["kind"] in ("root", "abstract"): self.assertIsNotNone(s.get("predicate"), n) @@ -323,28 +326,125 @@ def _enum_from_headers(end_marker: str) -> dict: f"(searched {len(_HEADERS)} under {[str(d) for d in _HEADER_DIRS]})") +_SYNTHETIC_CATALOG = """ +static const reltype_catalog_struct MEOS_RELTYPE_CATALOG[] = +{ + [T_TBOOL] = { .type_bboxtype = T_TSTZSPAN, .temptype_basetype = T_BOOL }, + [T_TNEW] = { .type_bboxtype = T_TSTZSPAN, .temptype_basetype = T_NEW }, +}; + +bool +demo_type(MeosType type) +{ + return (type == T_TBOOL || + /* the doubleX are internal aggregation types */ + type == T_TDOUBLE2 || + type == T_TNEW); +} +""" + + +class MembershipDerivationTest(unittest.TestCase): + """The membership comes from the source, so a type MEOS adds arrives free.""" + + def test_a_type_added_to_a_predicate_is_read_with_no_model_edit(self): + # The property the derivation exists for. + self.assertEqual(predicate_temptypes(_SYNTHETIC_CATALOG, "demo_type"), + ["T_TBOOL", "T_TNEW"]) + + def test_the_internal_aggregation_types_stay_out_of_the_model(self): + self.assertNotIn("T_TDOUBLE2", + predicate_temptypes(_SYNTHETIC_CATALOG, "demo_type")) + + def test_a_predicate_the_source_does_not_declare_raises(self): + with self.assertRaises(MembershipUnavailable): + predicate_temptypes(_SYNTHETIC_CATALOG, "no_such_type") + + def test_derive_fills_predicate_nodes_and_leaf_base_types(self): + nodes = { + "Demo": {"kind": "root", "predicate": "demo_type"}, + "TBool": {"kind": "leaf", "predicate": None, + "temptypes": ["T_TBOOL"]}, + } + derive_membership(nodes, _SYNTHETIC_CATALOG, + temptype_basetypes(_SYNTHETIC_CATALOG)) + self.assertEqual(nodes["Demo"]["temptypes"], ["T_TBOOL", "T_TNEW"]) + self.assertEqual(nodes["TBool"]["cBaseType"], "T_BOOL") + + def test_a_leaf_whose_type_the_catalog_does_not_relate_raises(self): + nodes = {"TGhost": {"kind": "leaf", "predicate": None, + "temptypes": ["T_TGHOST"]}} + with self.assertRaises(MembershipUnavailable): + derive_membership(nodes, _SYNTHETIC_CATALOG, + temptype_basetypes(_SYNTHETIC_CATALOG)) + + def test_attaching_without_the_source_says_so_rather_than_emptying(self): + # A class naming no type would read the same as one MEOS has no type + # for, so the status says which it is — as the error contract does. + saved = os.environ.pop("MDB_SRC_ROOT", None) + try: + om = attach_object_model({"functions": []}, MODEL, None)["objectModel"] + self.assertEqual(om["membership"]["status"], "source-unavailable") + self.assertNotIn("temptypes", om["lattice"]["Temporal"]) + finally: + if saved is not None: + os.environ["MDB_SRC_ROOT"] = saved + + @unittest.skipUnless(_CAT_C and _CAT_C.exists(), + "MobilityDB sources not available (run setup.py)") + def test_attaching_with_the_source_says_it_derived(self): + om = attach_object_model({"functions": []}, MODEL, _SRC)["objectModel"] + self.assertEqual(om["membership"]["status"], "derived") + self.assertIn("T_TPOSECHAIN", om["lattice"]["Temporal"]["temptypes"]) + + @unittest.skipUnless(_CAT_C and _CAT_C.exists(), "MobilityDB sources not available (run setup.py)") class DriftGate(unittest.TestCase): + """What the ATTACHED model says, against what MEOS says. + + The model file no longer states the membership, so there is no copy left to + drift; what these hold is that the derivation reaches the published lattice + and answers what the source answers. + """ + @classmethod def setUpClass(cls): cls.d = json.loads(MODEL.read_text()) cls.cat = _CAT_C.read_text(errors="ignore") - cls.lat = _nodes(cls.d["lattice"]) + cls.attached = attach_object_model( + {"functions": []}, MODEL, _SRC)["objectModel"] + cls.lat = _nodes(cls.attached["lattice"]) + + def test_the_model_file_states_no_membership_of_its_own(self): + # The property the derivation exists for: a copy is what drifts, so + # there must be none to drift. A leaf still names the ONE type it + # models — that is the class's identity, not a copy of MEOS. + for node, spec in _nodes(self.d["lattice"]).items(): + if spec.get("predicate"): + self.assertNotIn("temptypes", spec, f"{node} copies membership") + self.assertNotIn("cBaseType", spec, f"{node} copies its base type") + for name, t in _nodes(self.d["traits"]).items(): + self.assertNotIn("temptypes", t, f"{name} copies membership") def test_predicate_membership_matches_source(self): + checked = 0 for node, spec in self.lat.items(): pred = spec.get("predicate") if not pred: continue - derived = _predicate_temptypes(self.cat, pred) - _INTERNAL + derived = set(_predicate_temptypes(self.cat, pred)) - _INTERNAL self.assertEqual(set(spec["temptypes"]), derived, - f"{node} ({pred}) drifted from MEOS") + f"{node} ({pred}) does not answer its predicate") + checked += 1 + self.assertTrue(checked, "no node carries a predicate — derivation ran?") def test_traits_match_source(self): - for name, t in _nodes(self.d["traits"]).items(): - derived = _predicate_temptypes(self.cat, t["predicate"]) - self.assertEqual(set(t["temptypes"]), derived, name) + traits = _nodes(self.attached["traits"]) + self.assertTrue(traits, "no trait attached") + for name, t in traits.items(): + derived = set(_predicate_temptypes(self.cat, t["predicate"])) + self.assertEqual(set(t["temptypes"]), derived - _INTERNAL, name) def test_leaf_base_types_match_catalog(self): # The relation is a `.temptype_basetype` field of the type-indexed @@ -352,11 +452,14 @@ def test_leaf_base_types_match_catalog(self): # that array rather than matching its shape a second time here. pairs = temptype_basetypes(self.cat) self.assertTrue(pairs, "MEOS_RELTYPE_CATALOG yielded no base type") + leaves = 0 for node, spec in self.lat.items(): if spec["kind"] == "leaf": tt = spec["temptypes"][0] self.assertEqual(spec["cBaseType"], pairs[tt], - f"{node} base type drifted") + f"{node} base type does not answer the catalog") + leaves += 1 + self.assertTrue(leaves, "no leaf attached") @unittest.skipUnless(_HEADERS, "MEOS public headers not available") def test_enums_match_source(self): diff --git a/tests/test_typerelations.py b/tests/test_typerelations.py index ccc19c6..a3965d5 100644 --- a/tests/test_typerelations.py +++ b/tests/test_typerelations.py @@ -15,7 +15,7 @@ ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) -from parser.typerelations import _locate_catalog, attach_type_relations +from parser.typerelations import attach_type_relations, locate_catalog from parser.object_model import find_mobilitydb_src _FIXTURE = """ @@ -119,11 +119,11 @@ class TypeRelationsSourceTest(unittest.TestCase): def test_canonical_numeric_mappings(self): # Resolve the tree the way the extractor does, so the live assertion runs wherever the # extractor runs: find_mobilitydb_src reads $MOBILITYDB_SRC, while the provisioning that - # derives the catalog checks the repository out under $MDB_SRC_ROOT, which _locate_catalog + # derives the catalog checks the repository out under $MDB_SRC_ROOT, which locate_catalog # consults. Resolving through only the first skipped this check on the build path that # produces the catalog, which is the path whose drift it exists to catch. src = find_mobilitydb_src() - if src is None and _locate_catalog(None) is None: + if src is None and locate_catalog(None) is None: self.skipTest("MobilityDB source not available") by_base = attach_type_relations({}, src)["typeRelations"]["byBase"] self.assertEqual(by_base["float8"]["spanset"], "floatspanset")