diff --git a/README.md b/README.md index fb0a8d0..83e021f 100644 --- a/README.md +++ b/README.md @@ -10,13 +10,15 @@ This catalog is the foundation for generating language bindings (Python, Java, R - [Getting started](#getting-started) - [Output format](#output-format) - [Adding metadata](#adding-metadata) +- [The object model](#the-object-model) ## How it works -The pipeline runs in two steps: +The pipeline runs in three steps: 1. **Parser** — scans the MEOS `.h` header files using libclang and extracts every function signature, struct, and enum into structured JSON. 2. **Merger** — enriches the parser output with manual annotations from `meta/meos-meta.json`, such as documentation and memory ownership rules. +3. **Object model** — makes the *implicit* MEOS class hierarchy explicit: it derives the class lattice and assigns every function to the class it is a method of, from the canonical mapping in `meta/object-model.json`. See [The object model](#the-object-model). ## Getting started @@ -51,7 +53,7 @@ python setup.py --branch v1.2.0 python run.py ``` -The result is written to `output/meos-api.json`. +The result is written to `output/meos-idl.json`. You can also point the tool at a different headers directory: @@ -59,6 +61,15 @@ You can also point the tool at a different headers directory: python run.py /path/to/custom/include ``` +The object-model step also derives the per-function error contract by +scanning the MobilityDB C sources (`_mobilitydb/meos/src`, fetched by +`setup.py`). To audit the derived lattice against the most mature +hand-built model (PyMEOS): + +```bash +python object_model_parity.py # -> output/meos-object-model-parity.json +``` + ## Output format `meos-api.json` contains 3 top-level arrays: `functions`, `structs`, and `enums`. @@ -80,6 +91,28 @@ A typical function entry looks like this: } ``` +In addition, `meos-idl.json` carries an `objectModel` block: the explicit +class lattice (`classes`, `lattice`), the reverse index assigning each +function to the class it is a method of (`functionToClass`), the +closed-algebra companion hierarchies (`companions`), the error contract +(`errors`), and the irregularity worklist (`corrections`). + ## Adding metadata Manual annotations (ownership rules, additional documentation, deprecation flags, etc.) live in `meta/meos-meta.json`. The merger applies them on top of the libclang-parsed structure when generating the final catalog. + +## The object model + +MEOS is C — it has no classes. The object model is encoded by convention +in the `Temporal`/`TInstant`/`TSequence`/`TSequenceSet` struct family (the +template axis), the `temptype` discriminator whose base type is the +missing template parameter (the type-family axis), and the function-name +prefixes that bind a function to the class it is a method of +(`temporal_*` = the late-bound superclass; `tnumber_*`/`tspatial_*`/ +`tpoint_*`/`tgeo_*` = abstract families; `tbool_*`/`tint_*`/… = exact +types). `meta/object-model.json` makes that lattice explicit so every +binding/engine derives the **same** classes and methods from one mapping. + +See [docs/object-model.md](docs/object-model.md) for the full +specification, the closed-algebra companion hierarchies, the error +contract, the parity audit, and the irregularity worklist. diff --git a/docs/object-model.md b/docs/object-model.md new file mode 100644 index 0000000..5700ee9 --- /dev/null +++ b/docs/object-model.md @@ -0,0 +1,248 @@ +# The MEOS object model + +`meta/object-model.json` is the **single codegen source of truth** for the +class hierarchy implicit in MEOS. The pipeline folds it into +`meos-idl.json` as `objectModel`. Every binding/engine (PyMEOS, JMEOS, +MEOS.NET, MobilityDuck, MobilitySpark, …) derives the **identical** +classes and methods from this one mapping, so the OO surface is no longer +re-curated by hand in each repo. + +## Why + +MEOS is C: it has no classes. The object model is encoded by *convention* +in three places: + +1. **The template axis** — the `Temporal` / `TInstant` / `TSequence` / + `TSequenceSet` struct family, discriminated by the `subtype` field. +2. **The type-family axis** — the `temptype` discriminator. Its *base + type* (e.g. `T_TFLOAT` → `T_FLOAT8`) is the missing template + parameter; this is the inheritance lattice. +3. **The method binding** — a function's name prefix says which class it + is a method of: `temporal_*` is the late-bound **superclass** (every + temporal type), `tnumber_*`/`tspatial_*`/`tpoint_*`/`tgeo_*` are the + abstract families, `tbool_*`/`tint_*`/`tfloat_*`/… are the exact leaf + types, `tinstant_*`/`tsequence_*`/`tsequenceset_*` are the template + subtypes. + +The most mature hand-built model (PyMEOS) is used as a parity **oracle**, +not the source of truth — it is a strict subset of today's MEOS. + +## The lattice + +Single-inheritance tree. The base type is the missing template parameter; +the geometry/geodetic distinction is a **trait** axis, not a parent (so +there is no diamond): + +The spatial subtree follows the authoritative MobilityDB manual +(Ch. 7 Figure 7.1): `TGeo` is the broad parent of every PostGIS-derived +type; `TPoint` is an API-level intermediate under `TGeo` (see +[Manual reconciliation](#manual-reconciliation)). + +``` +Temporal temporal_type (the late-bound superclass) +├─ TAlpha talpha_type {tbool, ttext} +│ ├─ TBool base BOOL +│ └─ TText base TEXT +├─ TNumber tnumber_type {tint, tfloat} +│ ├─ TInt base INT4 +│ └─ TFloat base FLOAT8 +└─ TSpatial tspatial_type + ├─ TGeo tgeo_type_all (PostGIS-derived; manual) + │ ├─ TPoint tpoint_type {tgeompoint, tgeogpoint} + │ │ ├─ TGeomPoint base GEOMETRY ·geometryBased + │ │ └─ TGeogPoint base GEOGRAPHY ·geodetic + │ ├─ TGeometry base GEOMETRY ·geometryBased + │ └─ TGeography base GEOGRAPHY ·geodetic + ├─ TCbuffer base CBUFFER (#if CBUFFER) + ├─ TNpoint base NPOINT (#if NPOINT) + ├─ TPose base POSE (#if POSE) + └─ TRGeometry base POSE (#if RGEO) +``` + +A **concrete class** is the product *leaf × subtype* — `TFloatSeq`, +`TGeomPointInst`, `TRGeometrySeqSet`. Methods of a node are inherited by +all descendants; `objectModel.lattice` carries the derived +`children`/`ancestors`/`depth` so consumers can expand the effective +method set per concrete class. + +`cbuffer`, `npoint`, `pose`, `rgeo` are **full leaf classes and in +scope** — never deferred. `trgeometry` is the user-facing name; internal +functions keep the `trgeo_` prefix and are **not** normalized. + +## Manual reconciliation + +The MobilityDB manual (Ch. 7, *Temporal Geometry Types*, Figure 7.1 +"Hierarchy of spatiotemporal types", source `doc/images/tspatial.svg`) is +the **authoritative conceptual model** for the spatial subtree. The model +reconciles to it exactly, with one documented difference: + +- The figure is **partial** — spatial-only; it omits the `Temporal` root + and the whole `TAlpha`/`TNumber` subtree (`OM-M6`). This model is the + complete superset. +- The figure makes **`TGeo` the broad parent** of `TGeometry`, + `TGeography`, `TGeomPoint`, `TGeogPoint` ("TGeo and its subtypes … + derived from the PostGIS types geometry and geography"). The model uses + the broad C predicate `tgeo_type_all` for `TGeo` class membership; + the narrow `tgeo_type()` (and the point-rejecting `tgeo_*` functions) + is the real irregularity, sharpened in `OM-M1`. +- The figure draws no `TPoint` node, but the C API has `tpoint_type()` + and a 25-function `tpoint_*` family that must bind to a class. The + model inserts **`TPoint` as an API-level abstract under `TGeo`** — the + single, documented addition (`OM-M6`). +- `tpcpoint`/`tpcpatch` (temporal point-cloud point/patch) are absent + from both master MEOS and Figure 7.1 (`OM-M7`); they are out of the + drift-gated source of truth and derived automatically once MEOS + defines them — never fabricated. +- Class names use the manual spelling (`TGeo`, `TNpoint`, `TCbuffer`, + `TPose`, `TRGeometry`); C prefixes (`tnpoint_`, `tcbuffer_`, + `trgeo_`) are unchanged. + +`tests/test_object_model.py::ModelFileTests::test_matches_manual_figure_7_1` +gates this: the model's spatial node set must equal the figure's nodes +plus `TPoint`, with the figure's parent edges intact — so the +reconciliation cannot silently regress. + +## Closed algebra: companion hierarchies + +MEOS is a closed algebra: temporal operations return and consume spans, +sets and boxes (`tnumber_to_span` → a `Span`, `temporal_time` → a +`TstzSpanSet`, `tnumber_to_tbox` → `TBox`). The methods cannot be typed +without these, so `objectModel.companions` carries two parallel +hierarchies — `Box` (`TBox`, `STBox`) and `Collection` +(`Set`/`Span`/`SpanSet` with the concrete int/bigint/float/text/date/ +tstz/geo/… leaves) — and `objectModel.algebra` records which companion a +temporal family yields. + +## Method assignment + +`objectModel.functionToClass` maps every catalog function to the class it +is a method of, by **longest-prefix match** (so `tgeompoint_*` beats +`tgeo_*`, `tsequenceset_*` beats `tsequence_*`, and `tfloatinst_*` +resolves to the concrete `TFloatInst`). The assignment **reuses the +function itself** as the backing symbol — equivalence by construction, no +C-symbol guessing. A function with no prefix match (operator overloads, +`datum_*`/`geo_*` base helpers, plumbing) is recorded honestly with +`class: null` and a reason — never force-fitted. + +## Dispatch metadata + +For 4 of the 6 temporal-type families the per-member argument→backing +routing is mechanically derivable from the `__` C-name +token model, so faithful codegen needs nothing more than +`functionToClass`. The **`geo`** (`TGeomPoint`/`TGeogPoint`) and +**`temporal`** (`TFloat`/`TInt`/`TBool`/`TText`) families encode *editorial* +dispatch decisions that are absent from the C signatures (e.g. a Python +`Point` vs `BaseGeometry` split routing to *different* backings; scalar +arguments passed **by value** with a per-member cast; `IntSet`→`FloatSet` +coercion via the superclass). `objectModel.dispatch` makes that routing a +**catalog fact**, transcribed verbatim from the PyMEOS cross-repo handoff +RFC #94 §3 (the source of truth — extracted from PyMEOS's working +hand-written oracle; never re-derived), so every binding's faithful +generator emits geo/temporal with equivalence by construction instead of +per-binding editorial guesses. + +`dispatch.geo` is **single-block** (`dispatch.geo.`; `TGeomPoint` +vs `TGeogPoint` is disambiguated at runtime by `geodeticFromSelf`). +`dispatch.temporal` is **per concrete type** — +`dispatch.temporal.{tfloat,tint,tbool,ttext}.` — fully resolved +(no ``/`` placeholders), because the editorial routing differs +per type (e.g. `tint` coerces Float→Int, the opposite of `tfloat`; +`tint.temporal_equal` takes the value uncast while `tfloat` casts; +`tbool` exposes only `temporal_equal/not_equal`/`at`/`minus`). + +Each member has an ordered `dispatch` table (`py` type token → `fn` +backing; optional `argTransform`/`extraArgs`/`coerce`+`via`/ +`geodeticFromSelf`; a `py:"scalar"` entry carries `scalarType`, the exact +`isinstance` test, e.g. `"float"`, `"int|float"`, `"bool"`, `"str"`), +plus `fallback` and `result`. The `py` token may be `"scalar"`, +`"self"`, a class name, or `"list[str]"` +(`isinstance(o, list) and isinstance(o[0], str)`). The tables are +transcribed verbatim from the hand-written oracle (RFC #94 §3 + the +complete extended §7) — never derived. + +### argTransform vocabulary + +`argTransform` is a **closed, named** vocabulary — each binding maps every +name to its own idiom; the set is finite because the editorial decisions +are finite: + +| Name | Meaning (PyMEOS idiom shown) | +|---|---| +| `geoToGserialized` | shapely geometry → GSERIALIZED (`geo_to_gserialized($o, )`) | +| `stboxToGeo` | STBox → geometry (`stbox_to_geo($o._inner)`) | +| `scalarCast` | scalar cast to the block's concrete base (`float($o)` for `tfloat`, `int($o)` for `tint`) | +| `scalarValue` | scalar passed by value as-is (`$o`) | +| `textsetMake` | `list[str]` → text set (`textset_make($o)`) | +| `innerPtr` | pass the wrapped C pointer (`$o._inner`) | +| `geodeticFromSelf` | the only runtime-self primitive (PyMEOS → `isinstance(self, TGeogPoint)`) | +| `coerce`+`via:super` | Python-side type coercion then delegate to the superclass method | + +## The error contract + +MEOS has a single raise mechanism: +`meos_error(int errlevel, int errcode, const char *fmt, ...)`, where +`errcode` is an `errorCode` enum value. `objectModel.errors.codes` carries +the full taxonomy (verbatim, drift-gated against `meos.h`). +`objectModel.errors.raises` is derived by a static scan of the MobilityDB +C sources: the literal `meos_error` codes in each function body, plus one +indirection level through the `ensure_*` argument guards (tagged +`via: "direct" | "ensure"`). If the sources are unavailable the scan is a +no-op and `errors.status = "source-unavailable"` — an honest signal, +never a fabricated empty set. + +## Parity audit + +`object_model_parity.py` is the object-model analogue of +`portable_parity.py`. It parses the PyMEOS factory (the oracle, never +hard-coded) and writes `output/meos-object-model-parity.json`: every +structural divergence (classes/abstracts/collections MEOS defines that +PyMEOS lacks) as a worklist entry. A divergence already explained by a +curated `corrections` item is `known`; an unexplained one is +`needs-correction`. `tests/test_object_model_parity.py` gates +**0 `needs-correction`** (every divergence has a stated correction) and +that nothing is silently dropped — the analogue of the portable +0-unbacked gate. If the oracle is absent the audit degrades to +`oracle-unavailable` (curated corrections still carried, no fabricated +verdict). + +## Irregularities (corrections worklist) + +Making the implicit model explicit surfaces irregularities in *both* +MEOS and PyMEOS (a decade of manual evolution). They are carried verbatim +in `objectModel.corrections` as a durable, reviewable worklist +(`OM-M*` = MEOS-side, `OM-P*` = PyMEOS-side), e.g.: + +- **OM-M1** the class `TGeo` is broad (manual = `tgeo_type_all`) but the + narrow C `tgeo_type()` and most `tgeo_*` functions reject points — + API applicability is narrower than class membership. +- **OM-M2** `tgeometry_type()` means *geometry-based (non-geodetic)*, not + *is the TGeometry type* — a misnomer paired with `tgeodetic_type()`. +- **OM-M3** `TRGeometry`'s base type is `T_POSE` (base ≠ name). +- **OM-M4** `talpha_type` is a real grouping with no user-facing class. +- **OM-M6** the manual Figure 7.1 is partial (spatial-only) and draws no + `TPoint`; the model is the superset and adds `TPoint` under `TGeo`. +- **OM-M7** `tpcpoint`/`tpcpatch` are planned but absent from master + MEOS and the figure — out of the drift-gated SoT until MEOS adds them. +- **OM-P1/P6/P7** PyMEOS lacks the `TGeometry/TGeography/TCbuffer/ + TNpoint/TPose/TRGeometry` leaves, the full Collection hierarchy, and + the `TSpatial`/`TGeo` abstract intermediates that MEOS defines. + +Reporting only — the fixes land as separate PRs in those repos by their +own sessions. + +## Drift gate + +The curated lattice cannot silently drift from MEOS: +`tests/test_object_model.py::DriftGate` re-derives every membership set +from the MobilityDB sources (the predicate bodies, `MEOS_TEMPTYPE_CATALOG`, +the `tempSubtype` and `errorCode` enums) and asserts the curated meta +matches. (Public model excludes the internal `T_TDOUBLE{2,3,4}` +aggregation types.) Run `python setup.py` to fetch the sources, then +`python3 tests/test_object_model.py`. + +## Provenance + +Discussion MobilityDB#861 (edge-to-cloud portability). Source of truth: +MobilityDB `meos/src/temporal/meos_catalog.c` (predicates + +`MEOS_TEMPTYPE_CATALOG`) and `meos/include/meos.h` (`tempSubtype`, +`errorCode`). Oracle: PyMEOS `pymeos/factory.py`. diff --git a/meta/object-model.json b/meta/object-model.json new file mode 100644 index 0000000..8349f2e --- /dev/null +++ b/meta/object-model.json @@ -0,0 +1,1461 @@ +{ + "_comment": "The implicit MEOS object model, made explicit — the single codegen source of truth for the class hierarchy and its methods. MEOS is C: it has no classes. The object model is encoded by convention in (1) the Temporal/TInstant/TSequence/TSequenceSet struct family discriminated by `subtype` (the template axis), (2) the `temptype` discriminator whose base type is the missing template parameter (the type-family axis), and (3) function-name prefixes that bind a function to the class it is a method of (temporal_* = the superclass, late-bound; tnumber_*/tspatial_*/tpoint_*/tgeo_* = abstract families; tbool_*/tint_*/... = exact types). This file makes that lattice explicit so every binding/engine (PyMEOS, JMEOS, MEOS.NET, MobilityDuck, ...) derives the SAME classes and methods from one mapping instead of re-curating it by hand. Curated canonical data verbatim; the parser only adds DERIVED lookups (children, depth, method assignment, reverse indexes) — no class is guessed.", + "provenance": { + "discussion": "MobilityDB#861 (edge-to-cloud portability); MEOS-API object-model generalization", + "matureModel": "PyMEOS (the most mature hand-built OO model) is used as the parity ORACLE, not the source of truth: it is a strict subset of today's MEOS (it lacks TGeometry/TGeography/TCBuffer/TNPoint/TPose/TRGeometry classes that MEOS now defines).", + "sourceOfTruth": "MobilityDB meos/src/temporal/meos_catalog.c — the type-family predicate functions and MEOS_TEMPTYPE_CATALOG are the authoritative membership oracle; meos/include/meos.h — the tempSubtype and errorCode enums. The regression test re-derives every membership set from these so this file cannot silently drift.", + "predicates": { + "temporal_type": "meos_catalog.c — all temporal types (superclass membership)", + "talpha_type": "meos_catalog.c — {T_TBOOL,T_TTEXT} (+ internal tdoubleN)", + "tnumber_type": "meos_catalog.c — {T_TINT,T_TFLOAT}", + "tnumber_basetype": "meos_catalog.c — {T_INT4,T_FLOAT8}", + "tspatial_type": "meos_catalog.c — points+geos (+ cbuffer/npoint/pose/rgeo, #if-gated)", + "tpoint_type": "meos_catalog.c — {T_TGEOMPOINT,T_TGEOGPOINT}", + "tgeo_type": "meos_catalog.c — {T_TGEOMETRY,T_TGEOGRAPHY}", + "tgeo_type_all": "meos_catalog.c — {T_TGEOMETRY,T_TGEOGRAPHY,T_TGEOMPOINT,T_TGEOGPOINT} (overlap — see corrections)", + "tgeometry_type": "meos_catalog.c — {T_TGEOMPOINT,T_TGEOMETRY} (the geometry-based TRAIT, not the TGeometry class — see corrections)", + "tgeodetic_type": "meos_catalog.c — {T_TGEOGPOINT,T_TGEOGRAPHY} (the geodetic TRAIT)", + "catalog": "MEOS_TEMPTYPE_CATALOG[] — temptype -> base type (the missing template parameter)" + }, + "manual": { + "_comment": "The MobilityDB manual is the AUTHORITATIVE source for the conceptual class tree of the spatial subtree. The figure is conceptual and partial (spatial-only; omits Temporal/TAlpha/TNumber and the planned tpcpoint/tpcpatch). This model reconciles to it: TGeo is the broad parent of all PostGIS-derived types (= tgeo_type_all); TPoint is added as an API-level intermediate under TGeo (not drawn in the figure) so the tpoint_* method family binds to a class.", + "chapter": "Ch.7 Temporal Geometry Types (doc/temporal_spatial_p1.xml), https://mobilitydb.github.io/MobilityDB/master/ch07.html", + "figure": "Figure 7.1 'Hierarchy of spatiotemporal types in MobilityDB' (doc/images/tspatial.svg)", + "figureNodes": [ + "TSpatial", + "TGeo", + "TGeometry", + "TGeography", + "TGeomPoint", + "TGeogPoint", + "TCbuffer", + "TNpoint", + "TPose", + "TRGeometry" + ], + "figureEdges": "TSpatial -> {TGeo, TCbuffer, TNpoint, TPose, TRGeometry}; TGeo -> {TGeometry, TGeography, TGeomPoint, TGeogPoint}", + "modelAdds": [ + "TPoint (API-level intermediate under TGeo; see OM-M6)" + ] + } + }, + "axes": { + "_comment": "Temporal is concretized along two orthogonal axes. A concrete class is the product leaf-family x subtype, e.g. TFloatSeq, TGeomPointInst.", + "subtype": { + "enum": "tempSubtype", + "_comment": "The template axis — Temporal/TInstant/TSequence/TSequenceSet. Values verbatim from meos.h tempSubtype; gated against source.", + "values": [ + { + "name": "ANYTEMPSUBTYPE", + "value": 0, + "class": null, + "prefix": null + }, + { + "name": "TINSTANT", + "value": 1, + "class": "TInstant", + "prefix": "tinstant" + }, + { + "name": "TSEQUENCE", + "value": 2, + "class": "TSequence", + "prefix": "tsequence" + }, + { + "name": "TSEQUENCESET", + "value": 3, + "class": "TSequenceSet", + "prefix": "tsequenceset" + } + ] + }, + "typeFamily": { + "_comment": "The type-family axis — the inheritance lattice; the leaf's base type is the missing template parameter. Single-inheritance TREE (the geometry/geodetic split is a TRAIT, not a parent, to avoid a diamond — see traits)." + } + }, + "lattice": { + "_comment": "Each node: kind (root|abstract|leaf); parent (class-tree parent, single inheritance); predicate (the MEOS C membership oracle); prefix(es) (function-name prefix(es) that bind methods to this node — methods of a node are inherited by all descendants); temptypes (MeosType members; gated against the predicate body); for leaves cBaseType (gated against MEOS_TEMPTYPE_CATALOG) and conditional (#if compile guard).", + "Temporal": { + "kind": "root", + "parent": null, + "predicate": "temporal_type", + "prefixes": [ + "temporal" + ], + "temptypes": [ + "T_TBOOL", + "T_TINT", + "T_TFLOAT", + "T_TTEXT", + "T_TGEOMPOINT", + "T_TGEOGPOINT", + "T_TGEOMETRY", + "T_TGEOGRAPHY", + "T_TCBUFFER", + "T_TNPOINT", + "T_TPOSE", + "T_TRGEOMETRY" + ], + "doc": "Superclass of every temporal type; temporal_* functions are late-bound over `subtype` and `temptype`." + }, + "TAlpha": { + "kind": "abstract", + "parent": "Temporal", + "predicate": "talpha_type", + "prefixes": [ + "talpha" + ], + "temptypes": [ + "T_TBOOL", + "T_TTEXT" + ], + "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": { + "kind": "leaf", + "parent": "TAlpha", + "predicate": null, + "prefixes": [ + "tbool" + ], + "temptypes": [ + "T_TBOOL" + ], + "cBaseType": "T_BOOL" + }, + "TText": { + "kind": "leaf", + "parent": "TAlpha", + "predicate": null, + "prefixes": [ + "ttext" + ], + "temptypes": [ + "T_TTEXT" + ], + "cBaseType": "T_TEXT" + }, + "TNumber": { + "kind": "abstract", + "parent": "Temporal", + "predicate": "tnumber_type", + "prefixes": [ + "tnumber" + ], + "temptypes": [ + "T_TINT", + "T_TFLOAT" + ], + "basePredicate": "tnumber_basetype", + "doc": "Temporal numbers; supports linear interpolation." + }, + "TInt": { + "kind": "leaf", + "parent": "TNumber", + "predicate": null, + "prefixes": [ + "tint" + ], + "temptypes": [ + "T_TINT" + ], + "cBaseType": "T_INT4" + }, + "TFloat": { + "kind": "leaf", + "parent": "TNumber", + "predicate": null, + "prefixes": [ + "tfloat" + ], + "temptypes": [ + "T_TFLOAT" + ], + "cBaseType": "T_FLOAT8" + }, + "TSpatial": { + "kind": "abstract", + "parent": "Temporal", + "predicate": "tspatial_type", + "prefixes": [ + "tspatial" + ], + "temptypes": [ + "T_TGEOMPOINT", + "T_TGEOGPOINT", + "T_TGEOMETRY", + "T_TGEOGRAPHY", + "T_TCBUFFER", + "T_TNPOINT", + "T_TPOSE", + "T_TRGEOMETRY" + ], + "doc": "Temporal types carrying an STBox spatial bounding box." + }, + "TGeo": { + "kind": "abstract", + "parent": "TSpatial", + "predicate": "tgeo_type_all", + "apiPredicate": "tgeo_type", + "prefixes": [ + "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": { + "kind": "abstract", + "parent": "TGeo", + "predicate": "tpoint_type", + "prefixes": [ + "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": { + "kind": "leaf", + "parent": "TPoint", + "predicate": null, + "prefixes": [ + "tgeompoint" + ], + "userFacingName": "TGeomPoint", + "temptypes": [ + "T_TGEOMPOINT" + ], + "cBaseType": "T_GEOMETRY", + "traits": [ + "geometryBased" + ] + }, + "TGeogPoint": { + "kind": "leaf", + "parent": "TPoint", + "predicate": null, + "prefixes": [ + "tgeogpoint" + ], + "userFacingName": "TGeogPoint", + "temptypes": [ + "T_TGEOGPOINT" + ], + "cBaseType": "T_GEOGRAPHY", + "traits": [ + "geodetic" + ] + }, + "TGeometry": { + "kind": "leaf", + "parent": "TGeo", + "predicate": null, + "prefixes": [ + "tgeometry" + ], + "userFacingName": "TGeometry", + "temptypes": [ + "T_TGEOMETRY" + ], + "cBaseType": "T_GEOMETRY", + "traits": [ + "geometryBased" + ] + }, + "TGeography": { + "kind": "leaf", + "parent": "TGeo", + "predicate": null, + "prefixes": [ + "tgeography" + ], + "userFacingName": "TGeography", + "temptypes": [ + "T_TGEOGRAPHY" + ], + "cBaseType": "T_GEOGRAPHY", + "traits": [ + "geodetic" + ] + }, + "TCbuffer": { + "kind": "leaf", + "parent": "TSpatial", + "predicate": null, + "prefixes": [ + "tcbuffer" + ], + "userFacingName": "TCbuffer", + "temptypes": [ + "T_TCBUFFER" + ], + "cBaseType": "T_CBUFFER", + "conditional": "CBUFFER" + }, + "TNpoint": { + "kind": "leaf", + "parent": "TSpatial", + "predicate": null, + "prefixes": [ + "tnpoint" + ], + "userFacingName": "TNpoint", + "temptypes": [ + "T_TNPOINT" + ], + "cBaseType": "T_NPOINT", + "conditional": "NPOINT" + }, + "TPose": { + "kind": "leaf", + "parent": "TSpatial", + "predicate": null, + "prefixes": [ + "tpose" + ], + "userFacingName": "TPose", + "temptypes": [ + "T_TPOSE" + ], + "cBaseType": "T_POSE", + "conditional": "POSE" + }, + "TRGeometry": { + "kind": "leaf", + "parent": "TSpatial", + "predicate": null, + "prefixes": [ + "trgeometry", + "trgeo" + ], + "userFacingName": "TRGeometry", + "internalPrefix": "trgeo", + "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." + } + }, + "traits": { + "_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" + ], + "doc": "Ellipsoidal base — geography." + } + }, + "prefixMap": { + "_comment": "function-name prefix -> {node, scope}. The classifier matches the LONGEST prefix first (so tgeompoint_ beats tgeo_, tsequenceset_ beats tsequence_, and constructors _ beat _). scope: superclass | family | exact | subtype | companion. A function with no prefix match (operator overloads, base-type helpers like datum_*/geo_*, plumbing) is recorded under functionToClass as class=null with reason — never force-fitted.", + "rule": "longest-prefix-wins; tie broken by first non-self parameter type matching the node's struct (Temporal/TInstant/...)", + "superclass": [ + "temporal" + ], + "family": [ + "talpha", + "tnumber", + "tspatial", + "tpoint", + "tgeo" + ], + "exact": [ + "tbool", + "ttext", + "tint", + "tfloat", + "tgeompoint", + "tgeogpoint", + "tgeometry", + "tgeography", + "tcbuffer", + "tnpoint", + "tpose", + "trgeo", + "trgeometry" + ], + "subtype": [ + "tinstant", + "tsequence", + "tsequenceset" + ], + "companion": [ + "span", + "spanset", + "set", + "tbox", + "stbox" + ] + }, + "companions": { + "_comment": "MEOS is a CLOSED ALGEBRA: temporal operations return/consume spans, sets and boxes. Without these companion hierarchies the methods cannot be typed (e.g. tnumber_to_span -> *Span, temporal_time -> TsTzSpanSet, tnumber_to_tbox -> TBox). Parallel hierarchies (not subclasses of Temporal). temptypes gated against the MeosType enum.", + "Box": { + "root": "Box", + "nodes": { + "Box": { + "kind": "root", + "parent": null, + "doc": "Bounding-box family." + }, + "TBox": { + "kind": "leaf", + "parent": "Box", + "prefixes": [ + "tbox" + ], + "temptype": "T_TBOX", + "doc": "Numeric x time box (bbox of TNumber)." + }, + "STBox": { + "kind": "leaf", + "parent": "Box", + "prefixes": [ + "stbox" + ], + "temptype": "T_STBOX", + "doc": "Space x time box (bbox of TSpatial)." + } + } + }, + "Collection": { + "root": "Collection", + "nodes": { + "Collection": { + "kind": "root", + "parent": null + }, + "Set": { + "kind": "abstract", + "parent": "Collection", + "prefixes": [ + "set" + ], + "doc": "Unordered set of base values." + }, + "Span": { + "kind": "abstract", + "parent": "Collection", + "prefixes": [ + "span" + ], + "doc": "Contiguous range over an ordered base type." + }, + "SpanSet": { + "kind": "abstract", + "parent": "Collection", + "prefixes": [ + "spanset" + ], + "doc": "Set of disjoint spans." + }, + "IntSet": { + "kind": "leaf", + "parent": "Set", + "temptype": "T_INTSET" + }, + "BigintSet": { + "kind": "leaf", + "parent": "Set", + "temptype": "T_BIGINTSET" + }, + "FloatSet": { + "kind": "leaf", + "parent": "Set", + "temptype": "T_FLOATSET" + }, + "TextSet": { + "kind": "leaf", + "parent": "Set", + "temptype": "T_TEXTSET" + }, + "DateSet": { + "kind": "leaf", + "parent": "Set", + "temptype": "T_DATESET" + }, + "TstzSet": { + "kind": "leaf", + "parent": "Set", + "temptype": "T_TSTZSET" + }, + "GeomSet": { + "kind": "leaf", + "parent": "Set", + "temptype": "T_GEOMSET" + }, + "GeogSet": { + "kind": "leaf", + "parent": "Set", + "temptype": "T_GEOGSET" + }, + "NpointSet": { + "kind": "leaf", + "parent": "Set", + "temptype": "T_NPOINTSET", + "conditional": "NPOINT" + }, + "PoseSet": { + "kind": "leaf", + "parent": "Set", + "temptype": "T_POSESET", + "conditional": "POSE" + }, + "CbufferSet": { + "kind": "leaf", + "parent": "Set", + "temptype": "T_CBUFFERSET", + "conditional": "CBUFFER" + }, + "IntSpan": { + "kind": "leaf", + "parent": "Span", + "temptype": "T_INTSPAN" + }, + "BigintSpan": { + "kind": "leaf", + "parent": "Span", + "temptype": "T_BIGINTSPAN" + }, + "FloatSpan": { + "kind": "leaf", + "parent": "Span", + "temptype": "T_FLOATSPAN" + }, + "DateSpan": { + "kind": "leaf", + "parent": "Span", + "temptype": "T_DATESPAN" + }, + "TstzSpan": { + "kind": "leaf", + "parent": "Span", + "temptype": "T_TSTZSPAN" + }, + "IntSpanSet": { + "kind": "leaf", + "parent": "SpanSet", + "temptype": "T_INTSPANSET" + }, + "BigintSpanSet": { + "kind": "leaf", + "parent": "SpanSet", + "temptype": "T_BIGINTSPANSET" + }, + "FloatSpanSet": { + "kind": "leaf", + "parent": "SpanSet", + "temptype": "T_FLOATSPANSET" + }, + "DateSpanSet": { + "kind": "leaf", + "parent": "SpanSet", + "temptype": "T_DATESPANSET" + }, + "TstzSpanSet": { + "kind": "leaf", + "parent": "SpanSet", + "temptype": "T_TSTZSPANSET" + } + } + } + }, + "algebra": { + "_comment": "Closed-algebra relations — which companion type a temporal family yields, so codegen can type returns/arguments. Curated from the canonical accessor functions; informative, not exhaustive.", + "relations": [ + { + "from": "Temporal", + "to": "TstzSpan", + "relation": "timeExtent", + "via": "temporal_to_tstzspan" + }, + { + "from": "Temporal", + "to": "TsTzSpanSet", + "relation": "time", + "via": "temporal_time", + "note": "TsTzSpanSet is the PyMEOS name for TstzSpanSet" + }, + { + "from": "TNumber", + "to": "Span", + "relation": "valueSpan", + "via": "tnumber_to_span" + }, + { + "from": "TNumber", + "to": "TBox", + "relation": "bbox", + "via": "tnumber_to_tbox" + }, + { + "from": "TSpatial", + "to": "STBox", + "relation": "bbox", + "via": "tspatial_to_stbox" + }, + { + "from": "Temporal", + "to": "Set", + "relation": "values", + "via": "_values / _valueset" + } + ] + }, + "errors": { + "_comment": "The MEOS error/exception contract. MEOS has a single raise mechanism: meos_error(int errlevel, int errcode, const char *fmt, ...) (meos.h). errcode is an `errorCode` enum value. The per-function `raises` set is DERIVED by static scan of the function definition body in MobilityDB meos/src (see derivation) — never fabricated.", + "enum": "errorCode", + "raiseSite": "meos_error(int errlevel, int errcode, const char *format, ...)", + "codes": [ + { + "name": "MEOS_SUCCESS", + "value": 0, + "meaning": "Successful operation" + }, + { + "name": "MEOS_ERR_INTERNAL_ERROR", + "value": 1, + "meaning": "Unspecified internal error" + }, + { + "name": "MEOS_ERR_INTERNAL_TYPE_ERROR", + "value": 2, + "meaning": "Internal type error" + }, + { + "name": "MEOS_ERR_VALUE_OUT_OF_RANGE", + "value": 3, + "meaning": "Internal out of range error" + }, + { + "name": "MEOS_ERR_DIVISION_BY_ZERO", + "value": 4, + "meaning": "Internal division by zero error" + }, + { + "name": "MEOS_ERR_MEMORY_ALLOC_ERROR", + "value": 5, + "meaning": "Internal malloc error" + }, + { + "name": "MEOS_ERR_AGGREGATION_ERROR", + "value": 6, + "meaning": "Internal aggregation error" + }, + { + "name": "MEOS_ERR_DIRECTORY_ERROR", + "value": 7, + "meaning": "Internal directory error" + }, + { + "name": "MEOS_ERR_FILE_ERROR", + "value": 8, + "meaning": "Internal file error" + }, + { + "name": "MEOS_ERR_INVALID_ARG", + "value": 10, + "meaning": "Invalid argument" + }, + { + "name": "MEOS_ERR_INVALID_ARG_TYPE", + "value": 11, + "meaning": "Invalid argument type" + }, + { + "name": "MEOS_ERR_INVALID_ARG_VALUE", + "value": 12, + "meaning": "Invalid argument value" + }, + { + "name": "MEOS_ERR_FEATURE_NOT_SUPPORTED", + "value": 13, + "meaning": "Feature not currently supported" + }, + { + "name": "MEOS_ERR_MFJSON_INPUT", + "value": 20, + "meaning": "MFJSON input error" + }, + { + "name": "MEOS_ERR_MFJSON_OUTPUT", + "value": 21, + "meaning": "MFJSON output error" + }, + { + "name": "MEOS_ERR_TEXT_INPUT", + "value": 22, + "meaning": "Text input error" + }, + { + "name": "MEOS_ERR_TEXT_OUTPUT", + "value": 23, + "meaning": "Text output error" + }, + { + "name": "MEOS_ERR_WKB_INPUT", + "value": 24, + "meaning": "WKB input error" + }, + { + "name": "MEOS_ERR_WKB_OUTPUT", + "value": 25, + "meaning": "WKB output error" + }, + { + "name": "MEOS_ERR_GEOJSON_INPUT", + "value": 26, + "meaning": "GEOJSON input error" + }, + { + "name": "MEOS_ERR_GEOJSON_OUTPUT", + "value": 27, + "meaning": "GEOJSON output error" + } + ], + "derivation": { + "sourceGlob": "/meos/src/**/*.c", + "direct": "Collect the 2nd argument symbol of every meos_error(...) call textually present in the function's definition body.", + "viaEnsure": "MEOS guards arguments through `ensure_*` helper predicates that themselves call meos_error. Build an ensureFn -> {codes} map from the ensure_* bodies and resolve ONE indirection level; tag those entries via=\"ensure\".", + "honesty": "Each raises entry carries via=\"direct\"|\"ensure\". If the source tree is unavailable the scan is a no-op: per-function raises is omitted and errors.status=\"source-unavailable\" — an honest signal, never an empty-set claim and never a fabricated verdict (mirrors portable_parity.py).", + "status": "pending-scan" + } + }, + "scope": { + "inScopeTypeFamilies": [ + "temporal", + "alpha", + "number", + "geo", + "point", + "cbuffer", + "npoint", + "pose", + "rgeo" + ], + "note": "cbuffer / npoint / pose / rgeo are FULL user-facing temporal types and ARE in scope — full leaf classes like every other type, never deferred or excluded from any parity headline. The companion Box and Collection hierarchies are in scope because the closed algebra requires them to type method returns/arguments. Where the parity oracle (PyMEOS) lacks a class that MEOS defines, that is incomplete work in the oracle to close (a gap with a stated correction), never an accepted exclusion of the MEOS class." + }, + "notes": [ + "Derive classes and methods by REUSING the MEOS prefix convention (equivalence by construction), never by reimplementing or guessing; a function with no prefix match is recorded honestly as unclassified with a reason, never force-fitted.", + "Single-inheritance class TREE; the geometry/geodetic distinction is a TRAIT axis, not a parent, to avoid a diamond — a clarifying correction over ad-hoc hand-built models.", + "User-facing API uses the full name `trgeometry`; internal functions keep the `trgeo_` prefix — do NOT normalize the internal prefix.", + "Goal: 100% — every public MEOS function is assigned to exactly one class (or honestly recorded as a free/operator/plumbing function), and the derived lattice is a superset-correct, drift-gated reflection of MEOS." + ], + "corrections": { + "_comment": "Irregularities found while making the model explicit, surfaced as a durable, reviewable worklist (the user asked for corrections on every irregularity). side=meos|pymeos. These SEED object_model_parity.py; the parity audit may add more. Reporting only — fixes land as separate PRs in those repos by their own sessions.", + "items": [ + { + "id": "OM-M1", + "side": "meos", + "severity": "naming", + "location": "meos/src/temporal/meos_catalog.c tgeo_type() vs tgeo_type_all()", + "observed": "The MobilityDB manual (Ch.7 Figure 7.1) is authoritative: the CLASS TGeo is the broad parent of {tgeometry,tgeography,tgeompoint,tgeogpoint} (= the C predicate tgeo_type_all). But the C predicate tgeo_type() is narrow {tgeometry,tgeography} and most tgeo_* functions reject points — so the tgeo_* API is narrower than TGeo class membership. The two C predicates with near-identical names encode different scopes (class vs API applicability).", + "suggested": "Treat tgeo_type_all() as the class-membership predicate for TGeo (per the manual) and rename the narrow tgeo_type() to e.g. tgeo_nonpoint_type() (or generalise the tgeo_* functions to accept points). Codegen binds tgeo_* methods to TGeo but must mark them not-applicable to the TPoint subtree until resolved." + }, + { + "id": "OM-M2", + "side": "meos", + "severity": "naming", + "location": "meos/src/temporal/meos_catalog.c tgeometry_type()", + "observed": "tgeometry_type() = {tgeompoint,tgeometry} means 'geometry-based (non-geodetic)', NOT 'is the TGeometry type'. Misleads readers into thinking it selects TGeometry.", + "suggested": "Rename to tgeometric_type()/tspatial_geometric_type() (paired with tgeodetic_type()) to express the planar-vs-ellipsoidal trait axis unambiguously." + }, + { + "id": "OM-M3", + "side": "meos", + "severity": "doc", + "location": "MEOS_TEMPTYPE_CATALOG[] {T_TRGEOMETRY, T_POSE}", + "observed": "TRGeometry's base type is T_POSE, not a geometry — base type != type name, surprising and undocumented at the model level.", + "suggested": "Document the rigid-geometry = pose-backed design in the type catalog; codegen must read cBaseType from the catalog, never infer it from the class name." + }, + { + "id": "OM-M4", + "side": "meos", + "severity": "modelling", + "location": "meos/src/temporal/meos_catalog.c talpha_type()", + "observed": "talpha_type() = {tbool,ttext}(+internal tdoubleN) is a real intermediate grouping with no user-facing class name; PyMEOS has no TAlpha. The non-number/non-spatial branch is implicit.", + "suggested": "Adopt TAlpha as the documented abstract class for step-interpolated scalar temporals (parent of TBool/TText); generators expose it as an abstract base." + }, + { + "id": "OM-M5", + "side": "meos", + "severity": "naming", + "location": "meos/include/meos_pose.h (commit 70817cd23)", + "observed": "tpose_to_tpoint -> tpose_to_tgeompoint and tdistance_tpose_point -> tdistance_tpose_geo renames show the prefix grammar strains on overloaded 'point'/'geo'.", + "suggested": "Adopt a consistent suffix grammar ecosystem-wide: _to_, and ∈ {geo,point,tpoint,...} naming the exact argument shape; apply via the signature-uniformization worklist." + }, + { + "id": "OM-M6", + "side": "meos", + "severity": "doc", + "location": "MobilityDB manual Ch.7 Figure 7.1 (doc/images/tspatial.svg)", + "observed": "The published class-hierarchy figure is conceptual and PARTIAL: (a) it is spatial-only — it omits the Temporal root and the TAlpha/TNumber/TBool/TInt/TFloat/TText subtree; (b) it draws tgeompoint/tgeogpoint as direct TGeo subtypes with no TPoint node, yet the C API has tpoint_type() + a 25-function tpoint_* family that needs a class to bind to. This model is a superset that reconciles both: TPoint is inserted as an API-level abstract under TGeo.", + "suggested": "Either add TPoint to Figure 7.1 (and the omitted non-spatial subtree to a companion figure) or annotate the figure as the conceptual spatial view; document that the API recognises a TPoint grouping under TGeo." + }, + { + "id": "OM-M7", + "side": "meos", + "severity": "missing-type", + "location": "MEOS catalog (MeosType enum) + manual Figure 7.1", + "observed": "tpcpoint (temporal point-cloud point) and tpcpatch (temporal point-cloud patch) are absent from master MEOS (0 hits in meos/include, meos/src, doc/*.xml) and from Figure 7.1. They are planned spatial leaf types not yet in the drift-gated source of truth.", + "suggested": "Add T_TPCPOINT/T_TPCPATCH to the MeosType enum + MEOS_TEMPTYPE_CATALOG and the tpcpoint_*/tpcpatch_* API; this model derives them automatically once present (TSpatial conditional-guarded leaves). Until then they are honestly out of scope, never fabricated." + }, + { + "id": "OM-P1", + "side": "pymeos", + "severity": "missing-class", + "location": "PyMEOS pymeos/factory.py _TemporalFactory._mapper", + "observed": "Only 6 leaf families x 3 subtypes (18 entries): TBool/TInt/TFloat/TText/TGeomPoint/TGeogPoint. Missing the temporal types MEOS now defines: TGeometry, TGeography, TCbuffer, TNpoint, TPose, TRGeometry (x3 subtypes = 18 missing classes).", + "suggested": "Generate the full leaf x subtype matrix from this model (codegen) so PyMEOS is a complete, drift-gated reflection of MEOS instead of a hand-maintained subset." + }, + { + "id": "OM-P2", + "side": "pymeos", + "severity": "missing-class", + "location": "PyMEOS pymeos/temporal, pymeos/main", + "observed": "No TAlpha intermediate; TBool/TText hang directly off Temporal, so the talpha_* method group has no home class.", + "suggested": "Introduce TAlpha (abstract, parent of TBool/TText) carrying talpha_* methods." + }, + { + "id": "OM-P3", + "side": "pymeos", + "severity": "code-smell", + "location": "PyMEOS pymeos/main/tpoint.py (TGeomPointInst/TGeogPointInst)", + "observed": "_make_function = lambda *args: None and _cast_function = lambda x: None are non-functional placeholders to satisfy the abstract template; the real constructor is bypassed.", + "suggested": "Wire the real inst_make constructor (derivable from the model's subtype-constructor mapping) or restructure the template so the hole is unnecessary." + }, + { + "id": "OM-P4", + "side": "pymeos", + "severity": "type-axis", + "location": "PyMEOS pymeos/main/tpoint.py TPoint(Temporal[shp.Point,...])", + "observed": "TPoint uses shapely BaseGeometry as the base-type parameter while every other family uses a scalar base — the base-type axis is inconsistent across families.", + "suggested": "Align the generic base parameter with the model's cBaseType (GEOMETRY/GEOGRAPHY) and a consistent wrapper type." + }, + { + "id": "OM-P5", + "side": "pymeos", + "severity": "naming", + "location": "PyMEOS pymeos/main/tpoint.py bearing()", + "observed": "bearing() dispatches to bearing_tpoint_point/bearing_tpoint_tpoint — method placed on TPoint but the C name is not a tpoint_* prefix; ad-hoc vs the consistent tpoint_azimuth.", + "suggested": "Record bearing_* under functionToClass as an operator/free function with a curated canonical home (TPoint), so every binding places it identically instead of per-binding ad hoc." + }, + { + "id": "OM-P6", + "side": "pymeos", + "severity": "missing-class", + "location": "PyMEOS pymeos/factory.py _CollectionFactory._mapper", + "observed": "Collection factory lacks BigintSet/Span/SpanSet and NpointSet/PoseSet/CbufferSet though MEOS defines those collection types.", + "suggested": "Generate the full Collection hierarchy from companions.Collection so closed-algebra returns are typeable on every binding." + }, + { + "id": "OM-P7", + "side": "pymeos", + "severity": "missing-class", + "location": "PyMEOS pymeos/main, pymeos/temporal", + "observed": "No abstract spatial intermediates: PyMEOS has TPoint but no TSpatial and no TGeo. Per the manual Figure 7.1 the tree is TSpatial -> TGeo -> {TGeometry, TGeography, TPoint -> {TGeomPoint, TGeogPoint}} with TCbuffer/TNpoint/TPose/TRGeometry under TSpatial; the tspatial_*/tgeo_* method groups have no home class.", + "suggested": "Introduce TSpatial (abstract, parent of TGeo/TCbuffer/TNpoint/TPose/TRGeometry) and make TGeo the abstract parent of TGeometry/TGeography/TPoint (matching the manual + tgeo_type_all), so tspatial_*/tgeo_* bind to a class." + } + ] + }, + "dispatch": { + "_comment": "Canonical argument->backing dispatch for OO members whose editorial routing is not derivable from the C-name token model. Transcribed VERBATIM (AST-extracted 1:1 from the hand-written pymeos tpoint/tfloat/tint/tbool/ttext oracle) from the PyMEOS cross-repo handoff RFC #94 (tools/oo_codegen/RFC-dispatch-metadata.md): geo.at/geo.distance from section 3, the complete extended set from section 7. Do not re-derive (section 6) - the prose recipe produced 5 verified errors. geo is single-block (TGeomPoint/TGeogPoint disambiguated at runtime via geodeticFromSelf); temporal is per-concrete dispatch.temporal.{tfloat,tint,tbool,ttext}. (adopted structural contract; no / placeholders). scalarType = the exact isinstance test for a py:scalar entry. Consumed by every binding's faithful OO codegen for equivalence by construction.", + "argTransformVocabulary": [ + "geoToGserialized", + "stboxToGeo", + "scalarCast", + "scalarValue", + "textsetMake", + "innerPtr", + "geodeticFromSelf", + "coerce", + "via:super" + ], + "geo": { + "at": { + "dispatch": [ + { + "py": "Point", + "fn": "tpoint_at_value", + "argTransform": "geoToGserialized", + "geodeticFromSelf": true + }, + { + "py": "BaseGeometry", + "fn": "tpoint_at_geom", + "argTransform": "geoToGserialized", + "geodeticFromSelf": true + }, + { + "py": "GeoSet", + "fn": "temporal_at_values" + }, + { + "py": "STBox", + "fn": "tgeo_at_stbox", + "extraArgs": [ + "true" + ] + } + ], + "fallback": "super", + "result": "temporal" + }, + "distance": { + "dispatch": [ + { + "py": "BaseGeometry", + "fn": "tdistance_tgeo_geo", + "argTransform": "geoToGserialized", + "geodeticFromSelf": true + }, + { + "py": "STBox", + "fn": "tdistance_tgeo_geo", + "argTransform": "stboxToGeo" + }, + { + "py": "TPoint", + "fn": "tdistance_tgeo_tgeo" + } + ], + "fallback": "raise", + "result": "temporal" + }, + "minus": { + "fallback": "super", + "result": "temporal", + "dispatch": [ + { + "py": "Point", + "fn": "tpoint_minus_value", + "argTransform": "geoToGserialized", + "geodeticFromSelf": true + }, + { + "py": "BaseGeometry", + "fn": "tpoint_minus_geom", + "argTransform": "geoToGserialized", + "geodeticFromSelf": true + }, + { + "py": "GeoSet", + "fn": "temporal_minus_values" + }, + { + "py": "STBox", + "fn": "tgeo_minus_stbox", + "extraArgs": [ + "true" + ] + } + ] + }, + "nearest_approach_distance": { + "fallback": "raise", + "result": "scalar", + "dispatch": [ + { + "py": "BaseGeometry", + "fn": "nad_tgeo_geo", + "argTransform": "geoToGserialized", + "geodeticFromSelf": true + }, + { + "py": "STBox", + "fn": "nad_tgeo_stbox" + }, + { + "py": "TPoint", + "fn": "nad_tgeo_tgeo" + } + ] + } + }, + "temporal": { + "tfloat": { + "always_equal": { + "fallback": "raise", + "result": "bool_gt0", + "dispatch": [ + { + "py": "scalar", + "scalarType": "float", + "fn": "always_eq_tfloat_float", + "argTransform": "scalarValue" + }, + { + "py": "self", + "fn": "always_eq_temporal_temporal" + } + ] + }, + "always_not_equal": { + "fallback": "raise", + "result": "bool_gt0", + "dispatch": [ + { + "py": "scalar", + "scalarType": "float", + "fn": "always_ne_tfloat_float", + "argTransform": "scalarValue" + }, + { + "py": "self", + "fn": "always_ne_temporal_temporal" + } + ] + }, + "ever_equal": { + "fallback": "raise", + "result": "bool_gt0", + "dispatch": [ + { + "py": "scalar", + "scalarType": "float", + "fn": "ever_eq_tfloat_float", + "argTransform": "scalarValue" + }, + { + "py": "self", + "fn": "ever_eq_temporal_temporal" + } + ] + }, + "ever_not_equal": { + "fallback": "raise", + "result": "bool_gt0", + "dispatch": [ + { + "py": "scalar", + "scalarType": "float", + "fn": "ever_ne_tfloat_float", + "argTransform": "scalarValue" + }, + { + "py": "self", + "fn": "ever_ne_temporal_temporal" + } + ] + }, + "temporal_equal": { + "fallback": "super", + "result": "temporal", + "dispatch": [ + { + "py": "scalar", + "scalarType": "int|float", + "fn": "teq_tfloat_float", + "argTransform": "scalarCast" + } + ] + }, + "temporal_not_equal": { + "fallback": "super", + "result": "temporal", + "dispatch": [ + { + "py": "scalar", + "scalarType": "int|float", + "fn": "tne_tfloat_float", + "argTransform": "scalarCast" + } + ] + }, + "at": { + "fallback": "super", + "result": "temporal", + "dispatch": [ + { + "py": "scalar", + "scalarType": "int|float", + "fn": "tfloat_at_value", + "argTransform": "scalarCast" + }, + { + "py": "IntSet", + "coerce": "to_floatset", + "via": "super" + }, + { + "py": "IntSpan", + "coerce": "to_floatspan", + "via": "super" + }, + { + "py": "IntSpanSet", + "coerce": "to_floatspanset", + "via": "super" + } + ] + }, + "minus": { + "fallback": "super", + "result": "temporal", + "dispatch": [ + { + "py": "scalar", + "scalarType": "int|float", + "fn": "tfloat_minus_value", + "argTransform": "scalarCast" + }, + { + "py": "IntSet", + "coerce": "to_floatset", + "via": "super" + }, + { + "py": "IntSpan", + "coerce": "to_floatspan", + "via": "super" + }, + { + "py": "IntSpanSet", + "coerce": "to_floatspanset", + "via": "super" + } + ] + } + }, + "tint": { + "always_equal": { + "fallback": "raise", + "result": "bool_gt0", + "dispatch": [ + { + "py": "scalar", + "scalarType": "int", + "fn": "always_eq_tint_int", + "argTransform": "scalarValue" + }, + { + "py": "self", + "fn": "always_eq_temporal_temporal" + } + ] + }, + "always_not_equal": { + "fallback": "raise", + "result": "bool_gt0", + "dispatch": [ + { + "py": "scalar", + "scalarType": "int", + "fn": "always_ne_tint_int", + "argTransform": "scalarValue" + }, + { + "py": "self", + "fn": "always_ne_temporal_temporal" + } + ] + }, + "ever_equal": { + "fallback": "raise", + "result": "bool_gt0", + "dispatch": [ + { + "py": "scalar", + "scalarType": "int", + "fn": "ever_eq_tint_int", + "argTransform": "scalarValue" + }, + { + "py": "self", + "fn": "ever_eq_temporal_temporal" + } + ] + }, + "ever_not_equal": { + "fallback": "raise", + "result": "bool_gt0", + "dispatch": [ + { + "py": "scalar", + "scalarType": "int", + "fn": "ever_ne_tint_int", + "argTransform": "scalarValue" + }, + { + "py": "self", + "fn": "ever_ne_temporal_temporal" + } + ] + }, + "temporal_equal": { + "fallback": "super", + "result": "temporal", + "dispatch": [ + { + "py": "scalar", + "scalarType": "int", + "fn": "teq_tint_int", + "argTransform": "scalarValue" + } + ] + }, + "temporal_not_equal": { + "fallback": "super", + "result": "temporal", + "dispatch": [ + { + "py": "scalar", + "scalarType": "int", + "fn": "tne_tint_int", + "argTransform": "scalarValue" + } + ] + }, + "at": { + "fallback": "super", + "result": "temporal", + "dispatch": [ + { + "py": "scalar", + "scalarType": "int|float", + "fn": "tint_at_value", + "argTransform": "scalarCast" + }, + { + "py": "FloatSet", + "coerce": "to_intset", + "via": "super" + }, + { + "py": "FloatSpan", + "coerce": "to_intspan", + "via": "super" + }, + { + "py": "FloatSpanSet", + "coerce": "to_intspanset", + "via": "super" + } + ] + }, + "minus": { + "fallback": "super", + "result": "temporal", + "dispatch": [ + { + "py": "scalar", + "scalarType": "int|float", + "fn": "tint_minus_value", + "argTransform": "scalarCast" + }, + { + "py": "FloatSet", + "coerce": "to_intset", + "via": "super" + }, + { + "py": "FloatSpan", + "coerce": "to_intspan", + "via": "super" + }, + { + "py": "FloatSpanSet", + "coerce": "to_intspanset", + "via": "super" + } + ] + } + }, + "tbool": { + "temporal_equal": { + "fallback": "super", + "result": "temporal", + "dispatch": [ + { + "py": "scalar", + "scalarType": "bool", + "fn": "teq_tbool_bool", + "argTransform": "scalarValue" + } + ] + }, + "temporal_not_equal": { + "fallback": "super", + "result": "temporal", + "dispatch": [ + { + "py": "scalar", + "scalarType": "bool", + "fn": "tne_tbool_bool", + "argTransform": "scalarValue" + } + ] + }, + "at": { + "fallback": "super", + "result": "temporal", + "dispatch": [ + { + "py": "scalar", + "scalarType": "bool", + "fn": "tbool_at_value", + "argTransform": "scalarValue" + } + ] + }, + "minus": { + "fallback": "super", + "result": "temporal", + "dispatch": [ + { + "py": "scalar", + "scalarType": "bool", + "fn": "tbool_minus_value", + "argTransform": "scalarValue" + } + ] + } + }, + "ttext": { + "always_equal": { + "fallback": "raise", + "result": "bool_gt0", + "dispatch": [ + { + "py": "scalar", + "scalarType": "str", + "fn": "always_eq_ttext_text", + "argTransform": "scalarValue" + }, + { + "py": "self", + "fn": "always_eq_temporal_temporal" + } + ] + }, + "always_not_equal": { + "fallback": "raise", + "result": "bool_gt0", + "dispatch": [ + { + "py": "scalar", + "scalarType": "str", + "fn": "always_ne_ttext_text", + "argTransform": "scalarValue" + }, + { + "py": "self", + "fn": "always_ne_temporal_temporal" + } + ] + }, + "ever_equal": { + "fallback": "raise", + "result": "bool_gt0", + "dispatch": [ + { + "py": "scalar", + "scalarType": "str", + "fn": "ever_eq_ttext_text", + "argTransform": "scalarValue" + }, + { + "py": "self", + "fn": "ever_eq_temporal_temporal" + } + ] + }, + "ever_not_equal": { + "fallback": "raise", + "result": "bool_gt0", + "dispatch": [ + { + "py": "scalar", + "scalarType": "str", + "fn": "ever_ne_ttext_text", + "argTransform": "scalarValue" + }, + { + "py": "self", + "fn": "ever_ne_temporal_temporal" + } + ] + }, + "temporal_equal": { + "fallback": "super", + "result": "temporal", + "dispatch": [ + { + "py": "scalar", + "scalarType": "str", + "fn": "teq_ttext_text", + "argTransform": "scalarValue" + } + ] + }, + "temporal_not_equal": { + "fallback": "super", + "result": "temporal", + "dispatch": [ + { + "py": "scalar", + "scalarType": "str", + "fn": "tne_ttext_text", + "argTransform": "scalarValue" + } + ] + }, + "at": { + "fallback": "super", + "result": "temporal", + "dispatch": [ + { + "py": "scalar", + "scalarType": "str", + "fn": "ttext_at_value", + "argTransform": "scalarValue" + }, + { + "py": "list[str]", + "fn": "temporal_at_values", + "argTransform": "textsetMake" + } + ] + }, + "minus": { + "fallback": "super", + "result": "temporal", + "dispatch": [ + { + "py": "scalar", + "scalarType": "str", + "fn": "ttext_minus_value", + "argTransform": "scalarValue" + }, + { + "py": "list[str]", + "fn": "temporal_minus_values", + "argTransform": "textsetMake" + } + ] + } + } + } + } +} diff --git a/object_model_parity.py b/object_model_parity.py new file mode 100644 index 0000000..7a8de66 --- /dev/null +++ b/object_model_parity.py @@ -0,0 +1,182 @@ +# Object-model parity audit — the meos-api.json analogue of +# portable_parity.py, for the class lattice. +# +# python run.py # catalog with `objectModel` +# python object_model_parity.py # -> output/meos-object-model-parity.json +# +# It cross-references the DERIVED lattice against the most mature hand-built +# OO model (PyMEOS, the oracle — parsed from pymeos/factory.py, never +# hard-coded) and surfaces every structural divergence as a worklist entry. +# A divergence already explained by a curated `corrections` item is marked +# `known`; a new one is `needs-correction`. Nothing is silently dropped and +# no verdict is fabricated: if the oracle is absent the audit degrades to an +# honest `oracle-unavailable` status (same philosophy as portable_parity.py). + +import json +import re +import sys +from pathlib import Path + +IN_PATH = (Path(sys.argv[1]) if len(sys.argv) > 1 + else Path("output/meos-idl.json")) +OUT_PATH = (Path(sys.argv[2]) if len(sys.argv) > 2 + else Path("output/meos-object-model-parity.json")) +# PyMEOS oracle: factory.py. Default = sibling checkout; overridable. +PYMEOS = (Path(sys.argv[3]) if len(sys.argv) > 3 + else Path(__file__).resolve().parent.parent + / "PyMEOS" / "pymeos" / "factory.py") + +_SUBTYPE = {"INSTANT": "TINSTANT", "SEQUENCE": "TSEQUENCE", + "SEQUENCE_SET": "TSEQUENCESET"} +_TEMPORAL_RE = re.compile( + r"\(\s*MeosType\.(\w+)\s*,\s*MeosTemporalSubtype\.(\w+)\s*\)\s*:\s*(\w+)") +_COLL_RE = re.compile(r"MeosType\.(\w+)\s*:\s*(\w+)") + + +def _parse_oracle(path: Path): + """Extract PyMEOS's factory matrix: temporal {(temptype,subtype):cls}, + collection {temptype:cls}. Returns None if unavailable.""" + if not Path(path).exists(): + return None + txt = Path(path).read_text() + # Split the two factories so collection regex doesn't catch temporal lines. + coll_start = txt.find("_CollectionFactory") + temporal_txt = txt[:coll_start] if coll_start > 0 else txt + coll_txt = txt[coll_start:] if coll_start > 0 else "" + temporal = {(t, _SUBTYPE.get(s, s)): c + for t, s, c in _TEMPORAL_RE.findall(temporal_txt)} + collection = {t: c for t, c in _COLL_RE.findall(coll_txt)} + return {"temporal": temporal, "collection": collection} + + +def build_parity(catalog: dict, oracle) -> dict: + om = catalog.get("objectModel") + if not om: + raise ValueError("catalog has no `objectModel` — run run.py") + + lat = {k: v for k, v in om["lattice"].items() if not k.startswith("_")} + leaves = {n: s for n, s in lat.items() if s["kind"] == "leaf"} + coll_nodes = {k: v for k, v in om["companions"]["Collection"]["nodes"] + .items() if not k.startswith("_") and v["kind"] == "leaf"} + known = {c["id"]: c for c in om["corrections"]["items"]} + + work = [] + + def add(kind, detail, status, ref=None): + work.append({"kind": kind, "detail": detail, "status": status, + **({"correction": ref} if ref else {})}) + + # Always carry the curated corrections (the user's standing requirement: + # every irregularity surfaced as durable, reviewable data). + for c in om["corrections"]["items"]: + add(f"curated:{c['side']}:{c['severity']}", + f"{c['id']} {c['location']} — {c['observed']}", + "known", c["id"]) + + if oracle is None: + return { + "status": "oracle-unavailable", + "oraclePath": str(PYMEOS), + "note": "PyMEOS factory.py not found; reporting curated " + "corrections only — no fabricated parity verdict.", + "total": len(work), "aligned": 0, + "divergences": len(work), + "worklist": work, + } + + # MEOS-derived concrete matrix (every leaf × the 3 subtypes) vs PyMEOS. + o_temporal = oracle["temporal"] + o_classes = set(o_temporal.values()) + for leaf, spec in sorted(leaves.items()): + tt = spec["temptypes"][0] + for tok, suf, sub in (("TINSTANT", "Inst", "TINSTANT"), + ("TSEQUENCE", "Seq", "TSEQUENCE"), + ("TSEQUENCESET", "SeqSet", "TSEQUENCESET")): + meos_cls = leaf + suf + if (tt, tok) not in o_temporal: + cid = next((i for i, c in known.items() + if c["side"] == "pymeos" + and "missing-class" in c["severity"] + and (leaf in c["observed"] + or "full leaf" in c["suggested"])), None) + add("concrete-missing-in-pymeos", + f"{meos_cls} ({tt},{tok}) defined by MEOS, absent from " + f"PyMEOS _TemporalFactory", + "known" if cid else "needs-correction", cid) + # PyMEOS classes with no MEOS leaf (should be none — superset check). + meos_concrete = {lf + s for lf in leaves + for s in ("Inst", "Seq", "SeqSet")} + for oc in sorted(o_classes - meos_concrete): + add("concrete-missing-in-meos", + f"PyMEOS defines {oc} with no corresponding MEOS leaf×subtype", + "needs-correction") + + # Abstract intermediates the oracle lacks (informational divergence). + pymeos_abstracts = {"TNumber", "TPoint", "TGeomPoint", "TGeogPoint", + "Temporal", "TInstant", "TSequence", "TSequenceSet", + "TBool", "TInt", "TFloat", "TText"} + for n, s in sorted(lat.items()): + if s["kind"] in ("root", "abstract") and n not in pymeos_abstracts: + cid = {"TAlpha": "OM-P2", "TSpatial": "OM-P7", + "TGeo": "OM-P7"}.get(n) + add("abstract-missing-in-pymeos", + f"{n} ({s.get('predicate')}) is a real MEOS grouping with " + f"no PyMEOS abstract class", + "known" if cid else "needs-correction", cid) + + # Collection hierarchy vs PyMEOS _CollectionFactory. + o_coll = set(oracle["collection"].keys()) + for node, spec in sorted(coll_nodes.items()): + if spec["temptype"] not in o_coll: + add("collection-missing-in-pymeos", + f"{node} ({spec['temptype']}) defined by MEOS, absent from " + f"PyMEOS _CollectionFactory", + "known", "OM-P6") + + aligned_concrete = len(meos_concrete & o_classes) + needs = [w for w in work if w["status"] == "needs-correction"] + return { + "status": "audited", + "oraclePath": str(PYMEOS), + "total": len(work), + "aligned": aligned_concrete, + "divergences": len(work), + "needsCorrection": len(needs), + "knownCorrections": len(work) - len(needs), + "byKind": _by_kind(work), + "summary": om["summary"], + "worklist": work, + } + + +def _by_kind(work): + out = {} + for w in work: + out[w["kind"]] = out.get(w["kind"], 0) + 1 + return out + + +def main() -> None: + if not IN_PATH.exists(): + sys.exit(f"Catalog not found: {IN_PATH} — run `python run.py` first.") + oracle = _parse_oracle(PYMEOS) + rep = build_parity(json.loads(IN_PATH.read_text()), oracle) + OUT_PATH.parent.mkdir(parents=True, exist_ok=True) + OUT_PATH.write_text(json.dumps(rep, indent=2)) + if rep["status"] == "oracle-unavailable": + print(f"[object-model-parity] oracle unavailable ({PYMEOS}); " + f"{rep['divergences']} curated corrections carried " + f"→ {OUT_PATH}", file=sys.stderr) + else: + print(f"[object-model-parity] {rep['aligned']} concrete classes " + f"aligned with PyMEOS; {rep['divergences']} divergences " + f"({rep['knownCorrections']} known, {rep['needsCorrection']} " + f"need a correction) → {OUT_PATH}", file=sys.stderr) + for w in rep["worklist"]: + if w["status"] == "needs-correction": + print(f" needs-correction: {w['kind']} — {w['detail']}", + file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/parser/object_model.py b/parser/object_model.py new file mode 100644 index 0000000..d9b78a3 --- /dev/null +++ b/parser/object_model.py @@ -0,0 +1,305 @@ +"""The implicit MEOS object model, made explicit — codegen source of truth. + +`meta/object-model.json` is the curated, authoritative lattice (the class +tree, its prefixes, the closed-algebra companion hierarchies, the error +contract). Folding it into the catalog means every binding/engine derives +the *identical* classes and methods from one mapping instead of +re-curating the implicit C convention by hand. + +This is curated canonical data, not a heuristic: classes are preserved +verbatim and only *derived* lookups are added — children/depth/ancestors +of the tree, the assignment of each catalog function to the class it is a +method of (by the MEOS prefix convention, longest-match — equivalence by +construction, the method *is* the C function), and the reverse index. No +class is invented; a function with no prefix match is recorded honestly as +unclassified with a reason, never force-fitted. + +The error contract (`raises`) is derived by a static scan of the +MobilityDB sources when available; if they are not, it degrades to an +honest `source-unavailable` signal rather than an empty-set claim — the +same philosophy as portable_parity.py. + +Pure dict → dict plus an optional text scan; no libclang. +""" + +import json +import os +import re +from pathlib import Path + + +def find_mobilitydb_src(headers_dir: Path | None = None) -> Path | None: + """Resolve the MobilityDB C source root for the error scan / drift gate. + + First existing of: $MOBILITYDB_SRC, the sparse-checkout + ``_mobilitydb/meos/src``, or the ``src`` sibling of the headers dir. + Returns None when no source tree is available — callers must degrade to + an honest signal, never fabricate. + """ + candidates = [] + env = os.environ.get("MOBILITYDB_SRC") + if env: + candidates.append(Path(env)) + candidates.append(Path("_mobilitydb") / "meos" / "src") + if headers_dir is not None: + candidates.append(Path(headers_dir).parent / "src") + for c in candidates: + if c.exists() and (c / "temporal" / "meos_catalog.c").exists(): + return c + return None + + +_SUBTYPE_SUFFIX = [("seqset", "SeqSet", "TSequenceSet"), + ("seq", "Seq", "TSequence"), + ("inst", "Inst", "TInstant")] + +# Extra real prefixes for concrete collection nodes whose C prefix is not the +# lower-cased node name (verified against the headers, not guessed). +_COMPANION_PREFIX_ALIASES = {"GeomSet": ["geomset", "geoset"]} + +_MEOS_ERROR_RE = re.compile(r"\bmeos_error\s*\(\s*[^,]+,\s*([A-Z][A-Z0-9_]+)") +_ENSURE_CALL_RE = re.compile(r"\b(ensure_[a-z0-9_]+)\s*\(") +_FUNC_SIG_RE = re.compile(r"^([A-Za-z_][\w \t\*]*?\b)?([A-Za-z_]\w*)\s*\(") + + +def _tree(nodes: dict) -> dict: + """Add children/depth/ancestors to a {name: {parent: ...}} node map.""" + children = {n: [] for n in nodes} + for n, spec in nodes.items(): + p = spec.get("parent") + if p: + children[p].append(n) + + def ancestors(n): + chain, p = [], nodes[n].get("parent") + while p: + chain.append(p) + p = nodes[p].get("parent") + return chain + + for n, spec in nodes.items(): + spec["children"] = sorted(children[n]) + anc = ancestors(n) + spec["ancestors"] = anc + spec["depth"] = len(anc) + return nodes + + +def _candidates(model: dict) -> list: + """All (prefix, target) pairs, longest prefix first. + + target = {"class", "scope", "axis"}. Compound prefixes + map to the concrete leaf×subtype class (a constructor/accessor of it). + """ + out = [] + lat = {k: v for k, v in model["lattice"].items() if not k.startswith("_")} + for name, spec in lat.items(): + scope = {"root": "superclass", "abstract": "family", + "leaf": "exact"}[spec["kind"]] + for pref in spec.get("prefixes", []): + out.append((pref, {"class": name, "scope": scope, + "axis": "typeFamily"})) + if spec["kind"] == "leaf": + for tok, suf, _sub in _SUBTYPE_SUFFIX: + out.append((pref + tok, + {"class": name + suf, "scope": "constructor", + "axis": "concrete", "concreteOf": name, + "subtype": _sub})) + for v in model["axes"]["subtype"]["values"]: + if v["prefix"]: + out.append((v["prefix"], {"class": v["class"], "scope": "subtype", + "axis": "subtype"})) + for fam in ("Box", "Collection"): + fnodes = {k: x for k, x in model["companions"][fam]["nodes"].items() + if not k.startswith("_")} + for name, spec in fnodes.items(): + prefs = list(spec.get("prefixes", [])) + if spec["kind"] == "leaf": + prefs += _COMPANION_PREFIX_ALIASES.get(name, [name.lower()]) + for pref in prefs: + out.append((pref, {"class": name, "scope": "companion", + "axis": fam.lower()})) + out.sort(key=lambda kv: len(kv[0]), reverse=True) + return out + + +def _classify(fn_name: str, candidates: list): + for pref, target in candidates: + if fn_name == pref or fn_name.startswith(pref + "_"): + return pref, target + return None, None + + +def _role(fn_name: str) -> str: + n = fn_name + if n.endswith("_make") or "_from_base" in n or "_from_mfjson" in n \ + or n.endswith("_in") or n.endswith("_from_wkb") \ + or n.endswith("_from_hexwkb") or n.endswith("_copy"): + return "constructor" + if n.endswith("_out") or "_as_text" in n or "_as_wkb" in n \ + or "_as_hexwkb" in n or "_as_mfjson" in n or "_as_ewkt" in n: + return "output" + if "_to_" in n or n.endswith("_to_tbox") or n.endswith("_to_stbox"): + return "conversion" + if "_at_" in n or "_minus_" in n or n.endswith("_at_value") \ + or n.endswith("_minus_value"): + return "restriction" + for agg in ("_tagg", "_extent_transfn", "_transfn", "_finalfn", + "_combinefn", "_tcount"): + if agg in n: + return "aggregate" + if any(n.endswith(c) for c in ("_eq", "_ne", "_lt", "_le", "_gt", "_ge", + "_cmp", "_overlaps", "_contains", + "_intersects", "_eq_temporal")): + return "predicate" + return "accessor" + + +def _scan_errors(src_root: Path, public: set) -> dict: + """Static scan: function → set of errorCode it can raise. + + Best-effort, brace-depth based. Builds an ``ensure_* → codes`` map and + resolves one indirection level (MEOS guards args through ensure_* + helpers that themselves call meos_error). Every entry is tagged + via="direct"|"ensure"; nothing is asserted that is not textually + present in the source. + """ + raw: dict[str, dict[str, set]] = {} # fn -> {direct:set, ens:set} + for cf in sorted(src_root.glob("**/*.c")): + try: + lines = cf.read_text(errors="ignore").splitlines() + except OSError: + continue + depth = 0 + cur = None + prev = "" + for ln in lines: + if depth == 0 and "{" in ln: + m = _FUNC_SIG_RE.match(ln) or _FUNC_SIG_RE.match(prev + ln) + if m: + cur = m.group(2) + raw.setdefault(cur, {"direct": set(), "ens": set()}) + if cur: + for c in _MEOS_ERROR_RE.findall(ln): + raw[cur]["direct"].add(c) + for e in _ENSURE_CALL_RE.findall(ln): + raw[cur]["ens"].add(e) + depth += ln.count("{") - ln.count("}") + if depth <= 0: + depth = 0 + cur = None + prev = ln if not ln.strip().endswith((";", "}", "{")) else "" + + ensure_codes = {f: v["direct"] for f, v in raw.items() + if f.startswith("ensure_")} + result = {} + for fn in public: + rec = raw.get(fn) + if not rec: + continue + codes = [] + for c in sorted(rec["direct"]): + codes.append({"code": c, "via": "direct"}) + seen = {c["code"] for c in codes} + for e in sorted(rec["ens"]): + for c in sorted(ensure_codes.get(e, ())): + if c not in seen: + codes.append({"code": c, "via": "ensure", "through": e}) + seen.add(c) + if codes: + result[fn] = codes + return result + + +def attach_object_model(idl: dict, path: Path, + mobilitydb_src: Path | None = None) -> dict: + """Attach ``idl["objectModel"]`` from the canonical lattice file.""" + if not Path(path).exists(): + return idl + model = json.loads(Path(path).read_text()) + + lat = _tree({k: v for k, v in model["lattice"].items() + if not k.startswith("_")}) + for fam in ("Box", "Collection"): + _tree({k: v for k, v in model["companions"][fam]["nodes"].items() + if not k.startswith("_")}) + + candidates = _candidates(model) + functions = idl.get("functions", []) + public = {f["name"] for f in functions} + + classes: dict[str, dict] = {} + function_to_class: dict[str, dict] = {} + unclassified: list[str] = [] + + for fn in functions: + name = fn["name"] + pref, tgt = _classify(name, candidates) + if tgt is None: + function_to_class[name] = { + "class": None, + "reason": "no-prefix-match (operator/base-helper/plumbing)"} + unclassified.append(name) + continue + cls = tgt["class"] + rec = classes.setdefault(cls, {"methods": []}) + method = {"function": name, "role": _role(name), + "scope": tgt["scope"], "backing": name} + rec["methods"].append(method) + function_to_class[name] = { + "class": cls, "scope": tgt["scope"], "axis": tgt["axis"], + "matchedPrefix": pref, "via": "prefix", "backing": name} + if "concreteOf" in tgt: + function_to_class[name]["concreteOf"] = tgt["concreteOf"] + function_to_class[name]["subtype"] = tgt["subtype"] + + # Error contract + errors = dict(model["errors"]) + if mobilitydb_src and Path(mobilitydb_src).exists(): + raises = _scan_errors(Path(mobilitydb_src), public) + errors["status"] = "scanned" + errors["raises"] = raises + errors["raisesCount"] = len(raises) + else: + errors["status"] = "source-unavailable" + errors["raises"] = {} + errors["raisesCount"] = 0 + + leaves = sorted(n for n, s in lat.items() if s["kind"] == "leaf") + abstracts = sorted(n for n, s in lat.items() + if s["kind"] in ("root", "abstract")) + concretes = sorted(c for c in classes + if c not in lat + and c not in model["companions"]["Box"]["nodes"] + and c not in model["companions"]["Collection"]["nodes"]) + + idl["objectModel"] = { + "provenance": model["provenance"], + "axes": model["axes"], + "lattice": lat, + "traits": model["traits"], + "companions": model["companions"], + "algebra": model["algebra"], + "errors": errors, + "scope": model["scope"], + "notes": model["notes"], + "corrections": model["corrections"], + "dispatch": model.get("dispatch", {}), + "classes": classes, + "functionToClass": function_to_class, + "summary": { + "latticeNodes": len(lat), + "abstractClasses": abstracts, + "leafClasses": leaves, + "concreteClasses": concretes, + "classesWithMethods": len(classes), + "functionsClassified": len(functions) - len(unclassified), + "functionsTotal": len(functions), + "unclassified": len(unclassified), + "coveragePct": (round((len(functions) - len(unclassified)) + * 100 / len(functions), 1) + if functions else 0.0), + "errorStatus": errors["status"], + }, + } + return idl diff --git a/run.py b/run.py index 0161d22..107e984 100644 --- a/run.py +++ b/run.py @@ -3,35 +3,55 @@ from pathlib import Path from parser.parser import parse_all_headers, merge_meta +from parser.object_model import attach_object_model, find_mobilitydb_src HEADERS_DIR = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("./meos/include") -META_PATH = Path("./meta/meos-meta.json") -OUTPUT_DIR = Path("./output") +META_PATH = Path("./meta/meos-meta.json") +OBJMODEL_PATH = Path("./meta/object-model.json") +OUTPUT_DIR = Path("./output") + +# MobilityDB C sources for the error-contract scan. Explicit argv[2] wins; +# otherwise resolved (env / _mobilitydb sparse checkout / src sibling). +# Absent → honest source-unavailable signal, never a fabricated empty set. +MOBILITYDB_SRC = (Path(sys.argv[2]) if len(sys.argv) > 2 + else find_mobilitydb_src(HEADERS_DIR)) def main(): OUTPUT_DIR.mkdir(parents=True, exist_ok=True) # 1. Parse C headers - print(f"[1/2] Parsing {HEADERS_DIR}...", file=sys.stderr) + print(f"[1/3] Parsing {HEADERS_DIR}...", file=sys.stderr) idl = parse_all_headers(HEADERS_DIR) # 2. Merge with manual metadata if META_PATH.exists(): - print(f"[2/2] Merging with {META_PATH}...", file=sys.stderr) + print(f"[2/3] Merging with {META_PATH}...", file=sys.stderr) idl = merge_meta(idl, META_PATH) else: - print(f"[2/2] No meta found at {META_PATH}, skipping.", file=sys.stderr) + print(f"[2/3] No meta found at {META_PATH}, skipping.", file=sys.stderr) + + # 3. Derive the explicit object model (class lattice + methods + error + # contract) from the implicit MEOS prefix convention. + print(f"[3/3] Deriving object model from {OBJMODEL_PATH} " + f"(error scan: {MOBILITYDB_SRC})...", file=sys.stderr) + idl = attach_object_model(idl, OBJMODEL_PATH, MOBILITYDB_SRC) idl_path = OUTPUT_DIR / "meos-idl.json" with open(idl_path, "w") as f: json.dump(idl, f, indent=2) print(f" → {idl_path} written", file=sys.stderr) + om = idl.get("objectModel", {}).get("summary", {}) print(f"\nDone: {len(idl['functions'])} functions, " f"{len(idl['structs'])} structs, " f"{len(idl['enums'])} enums", file=sys.stderr) + if om: + print(f" object model: {om['classesWithMethods']} classes, " + f"{om['functionsClassified']}/{om['functionsTotal']} functions " + f"classified ({om['coveragePct']}%), " + f"errors: {om['errorStatus']}", file=sys.stderr) if __name__ == "__main__": diff --git a/setup.py b/setup.py index 6094cfe..b66aa5e 100644 --- a/setup.py +++ b/setup.py @@ -42,7 +42,12 @@ def step_clone(branch: str) -> None: REPO_URL, str(CLONE_DIR), ]) - run(["git", "-C", str(CLONE_DIR), "sparse-checkout", "set", "meos/include", "postgres"]) + # `meos/src` is needed by the object-model stage: the error-contract + # scan and the lattice drift gate read the predicate bodies and + # MEOS_TEMPTYPE_CATALOG. Applied idempotently so existing clones pick + # it up on update too. + run(["git", "-C", str(CLONE_DIR), "sparse-checkout", "set", + "meos/include", "meos/src", "postgres"]) print(f" Done.") diff --git a/tests/test_object_model.py b/tests/test_object_model.py new file mode 100644 index 0000000..a8c5683 --- /dev/null +++ b/tests/test_object_model.py @@ -0,0 +1,274 @@ +"""Unit tests + drift gate for the explicit object model. + +Runs without libclang or pytest: python3 tests/test_object_model.py + +The DriftGate re-derives every lattice membership set from the MobilityDB +sources (the predicate bodies, MEOS_TEMPTYPE_CATALOG, the tempSubtype and +errorCode enums) and asserts the curated meta matches — so the source of +truth cannot silently drift away from MEOS. +""" + +import json +import re +import sys +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from parser.object_model import attach_object_model, find_mobilitydb_src + +MODEL = ROOT / "meta" / "object-model.json" +_INTERNAL = {"T_TDOUBLE2", "T_TDOUBLE3", "T_TDOUBLE4"} # not public classes + + +def _nodes(d): + return {k: v for k, v in d.items() if not k.startswith("_")} + + +class ModelFileTests(unittest.TestCase): + def setUp(self): + self.d = json.loads(MODEL.read_text()) + self.lat = _nodes(self.d["lattice"]) + + def test_lattice_is_a_well_formed_tree(self): + roots = [n for n, s in self.lat.items() if s["parent"] is None] + self.assertEqual(roots, ["Temporal"]) + for n, s in self.lat.items(): + if s["parent"] is not None: + self.assertIn(s["parent"], self.lat, f"{n} parent missing") + # no cycle: walking parents terminates at the root + seen, p = {n}, s["parent"] + while p: + self.assertNotIn(p, seen, f"cycle through {n}") + seen.add(p) + p = self.lat[p]["parent"] + self.assertIn("Temporal", seen | {n}) + + 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) + self.assertEqual(len(s["temptypes"]), 1, n) + if s["kind"] in ("root", "abstract"): + self.assertIsNotNone(s.get("predicate"), n) + + def test_companions_are_well_formed_trees(self): + for fam in ("Box", "Collection"): + nodes = _nodes(self.d["companions"][fam]["nodes"]) + roots = [n for n, s in nodes.items() if s["parent"] is None] + self.assertEqual(len(roots), 1, fam) + for n, s in nodes.items(): + if s["parent"]: + self.assertIn(s["parent"], nodes) + if s["kind"] == "leaf": + self.assertIn("temptype", s, n) + + def test_traits_are_not_inheritance(self): + # geometry/geodetic is a TRAIT axis, never a parent (no diamond). + trait_preds = {t["predicate"] + for t in _nodes(self.d["traits"]).values()} + for s in self.lat.values(): + self.assertNotIn(s.get("predicate"), trait_preds) + + def test_corrections_well_formed_and_unique(self): + items = self.d["corrections"]["items"] + ids = [c["id"] for c in items] + self.assertEqual(len(ids), len(set(ids)), "duplicate correction id") + for c in items: + self.assertIn(c["side"], ("meos", "pymeos")) + for k in ("location", "observed", "suggested"): + self.assertTrue(c[k].strip(), c["id"]) + self.assertIn("OM-P7", ids) # abstract spatial intermediates + + def test_matches_manual_figure_7_1(self): + # The MobilityDB manual Ch.7 Figure 7.1 is authoritative for the + # conceptual spatial tree. The model must contain exactly the + # figure's spatial nodes plus the single API-level addition TPoint + # (documented as OM-M6), and the figure's parent edges must hold. + man = self.d["provenance"]["manual"] + spatial = {n for n in self.lat + if n == "TSpatial" or self._under(n, "TSpatial")} + self.assertEqual(spatial, + set(man["figureNodes"]) | {"TPoint"}) + # TGeo -> {TGeometry, TGeography, TGeomPoint, TGeogPoint} (via TPoint) + for child in ("TGeometry", "TGeography"): + self.assertEqual(self.lat[child]["parent"], "TGeo") + for pt in ("TGeomPoint", "TGeogPoint"): + self.assertEqual(self.lat[pt]["parent"], "TPoint") + self.assertEqual(self.lat["TPoint"]["parent"], "TGeo") + self.assertEqual(self.lat["TGeo"]["parent"], "TSpatial") + # TSpatial -> {TGeo, TCbuffer, TNpoint, TPose, TRGeometry} + for leaf in ("TCbuffer", "TNpoint", "TPose", "TRGeometry"): + self.assertEqual(self.lat[leaf]["parent"], "TSpatial") + # the broad TGeo == tgeo_type_all (manual), not the narrow predicate + self.assertEqual(self.lat["TGeo"]["predicate"], "tgeo_type_all") + self.assertEqual(self.lat["TGeo"]["apiPredicate"], "tgeo_type") + + def _under(self, node, root): + p = self.lat[node]["parent"] + while p: + if p == root: + return True + p = self.lat[p]["parent"] + return False + + def test_scope_keeps_special_types_in(self): + for fam in ("cbuffer", "npoint", "pose", "rgeo"): + self.assertIn(fam, self.d["scope"]["inScopeTypeFamilies"]) + self.assertNotIn("excludedFamilies", self.d) + self.assertIn("never deferred or excluded", self.d["scope"]["note"]) + + +class AttachTests(unittest.TestCase): + CASES = { + "temporal_merge": ("Temporal", "superclass"), + "tnumber_integral": ("TNumber", "family"), + "tpoint_speed": ("TPoint", "family"), + "tgeo_centroid": ("TGeo", "family"), + "tfloat_degrees": ("TFloat", "exact"), + "tfloatinst_make": ("TFloatInst", "constructor"), + "tfloatseqset_from_base_tstzspanset": ("TFloatSeqSet", "constructor"), + "tgeompointinst_make": ("TGeomPointInst", "constructor"), + "trgeoinst_make": ("TRGeometryInst", "constructor"), + "trgeo_affine": ("TRGeometry", "exact"), + "tsequenceset_make": ("TSequenceSet", "subtype"), + "tcbuffer_make": ("TCbuffer", "exact"), + "span_lower": ("Span", "companion"), + "intset_make": ("IntSet", "companion"), + "stbox_expand": ("STBox", "companion"), + } + + def _attach(self, names): + return attach_object_model( + {"functions": [{"name": n} for n in names]}, MODEL, None) + + def test_classification(self): + idl = self._attach(list(self.CASES) + ["add_int_int"]) + ftc = idl["objectModel"]["functionToClass"] + for fn, (cls, scope) in self.CASES.items(): + self.assertEqual(ftc[fn]["class"], cls, fn) + self.assertEqual(ftc[fn]["scope"], scope, fn) + self.assertEqual(ftc[fn]["backing"], fn) # by construction + # honest unclassified — never force-fitted + self.assertIsNone(ftc["add_int_int"]["class"]) + self.assertIn("no-prefix-match", ftc["add_int_int"]["reason"]) + + def test_tree_derived(self): + om = self._attach(["temporal_merge"])["objectModel"] + lat = om["lattice"] + self.assertEqual(lat["Temporal"]["depth"], 0) + self.assertEqual(lat["TFloat"]["ancestors"], ["TNumber", "Temporal"]) + self.assertIn("TNumber", lat["Temporal"]["children"]) + self.assertEqual(lat["TFloat"]["depth"], 2) + + def test_longest_prefix_wins(self): + # tgeompoint_ must beat tgeo_; tsequenceset_ must beat tsequence_ + idl = self._attach([ + "tgeompoint_trajectory", "tgeo_centroid", + "tsequenceset_make", "tsequence_make"]) + ftc = idl["objectModel"]["functionToClass"] + self.assertEqual(ftc["tgeompoint_trajectory"]["class"], "TGeomPoint") + self.assertEqual(ftc["tgeo_centroid"]["class"], "TGeo") + self.assertEqual(ftc["tsequenceset_make"]["class"], "TSequenceSet") + self.assertEqual(ftc["tsequence_make"]["class"], "TSequence") + + def test_missing_file_is_noop(self): + idl = attach_object_model({"x": 1}, ROOT / "nope.json", None) + self.assertNotIn("objectModel", idl) + + def test_errors_source_unavailable_is_honest(self): + om = self._attach(["temporal_merge"])["objectModel"] + self.assertEqual(om["errors"]["status"], "source-unavailable") + self.assertEqual(om["errors"]["raises"], {}) # not fabricated + self.assertEqual(len(om["errors"]["codes"]), 21) + + +# --------------------------------------------------------------------------- +# Drift gate: the curated lattice must equal what MEOS actually defines. +# --------------------------------------------------------------------------- + +def _brace_body(text: str, start: int) -> str: + depth, i = 0, text.index("{", start) + j = i + while j < len(text): + depth += (text[j] == "{") - (text[j] == "}") + if depth == 0: + return text[i:j + 1] + j += 1 + return text[i:] + + +def _predicate_temptypes(cat_src: str, name: str) -> set: + m = re.search(r"\n" + name + r"\(MeosType \w+\)\s*", cat_src) + body = _brace_body(cat_src, m.end()) + return {t for t in re.findall(r"\bT_T[A-Z0-9_]+\b", body)} + + +def _enum_block(text: str, end_marker: str) -> dict: + end = text.index(end_marker) + start = text.rindex("typedef enum", 0, end) + block = text[start:end] + return {n: int(v) for n, v in + re.findall(r"\b([A-Z][A-Z0-9_]+)\s*=\s*(\d+)", block)} + + +_SRC = find_mobilitydb_src(ROOT / "meos" / "include") +_CAT_C = (_SRC / "temporal" / "meos_catalog.c") if _SRC else None +_MEOS_H = None +if _SRC: + for cand in (_SRC.parent / "include" / "meos.h", + ROOT / "meos" / "include" / "meos.h"): + if cand.exists(): + _MEOS_H = cand + break + + +@unittest.skipUnless(_CAT_C and _CAT_C.exists(), + "MobilityDB sources not available (run setup.py)") +class DriftGate(unittest.TestCase): + @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"]) + + def test_predicate_membership_matches_source(self): + for node, spec in self.lat.items(): + pred = spec.get("predicate") + if not pred: + continue + derived = _predicate_temptypes(self.cat, pred) - _INTERNAL + self.assertEqual(set(spec["temptypes"]), derived, + f"{node} ({pred}) drifted from MEOS") + + 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) + + def test_leaf_base_types_match_catalog(self): + pairs = dict(re.findall( + r"\{\s*(T_T[A-Z0-9_]+)\s*,\s*(T_[A-Z0-9_]+)\s*\}", self.cat)) + 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") + + @unittest.skipUnless(_MEOS_H and _MEOS_H.exists(), "meos.h not available") + def test_enums_match_source(self): + h = _MEOS_H.read_text(errors="ignore") + sub = _enum_block(h, "} tempSubtype;") + for v in self.d["axes"]["subtype"]["values"]: + self.assertEqual(sub[v["name"]], v["value"], v["name"]) + err = _enum_block(h, "} errorCode;") + for c in self.d["errors"]["codes"]: + self.assertEqual(err[c["name"]], c["value"], c["name"]) + self.assertEqual(len(self.d["errors"]["codes"]), len(err)) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_object_model_parity.py b/tests/test_object_model_parity.py new file mode 100644 index 0000000..8b40f51 --- /dev/null +++ b/tests/test_object_model_parity.py @@ -0,0 +1,91 @@ +"""Unit tests + CI gate for object_model_parity.py. + + python3 tests/test_object_model_parity.py + +The gate: every structural divergence from the PyMEOS oracle must be +explained by a curated `corrections` item (status `known`) — none may be +`needs-correction` and none may be silently dropped. This is the +object-model analogue of the portable-parity 0-unbacked gate. +""" + +import json +import sys +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from parser.object_model import attach_object_model +from object_model_parity import build_parity, _parse_oracle, PYMEOS + +MODEL = ROOT / "meta" / "object-model.json" +_CATALOG = ROOT / "output" / "meos-idl.json" + + +def _idl(names): + return attach_object_model( + {"functions": [{"name": n} for n in names]}, MODEL, None) + + +class ParityLogicTests(unittest.TestCase): + def test_requires_object_model(self): + with self.assertRaises(ValueError): + build_parity({"functions": []}, None) + + def test_oracle_unavailable_is_honest(self): + rep = build_parity(_idl(["temporal_merge"]), None) + self.assertEqual(rep["status"], "oracle-unavailable") + # curated corrections still carried; no fabricated parity verdict + self.assertGreater(rep["divergences"], 0) + self.assertTrue(all(w["status"] == "known" + for w in rep["worklist"])) + + def test_audited_against_fake_oracle(self): + # PyMEOS-shaped oracle missing every spatial leaf & abstract. + oracle = { + "temporal": {("T_TBOOL", "TINSTANT"): "TBoolInst", + ("T_TFLOAT", "TINSTANT"): "TFloatInst"}, + "collection": {"T_INTSET": "IntSet"}, + } + rep = build_parity(_idl(["temporal_merge"]), oracle) + self.assertEqual(rep["status"], "audited") + kinds = rep["byKind"] + self.assertIn("concrete-missing-in-pymeos", kinds) + self.assertIn("abstract-missing-in-pymeos", kinds) + self.assertIn("collection-missing-in-pymeos", kinds) + # nothing silently dropped + self.assertEqual(rep["knownCorrections"] + rep["needsCorrection"], + rep["divergences"]) + + def test_every_divergence_has_a_correction(self): + oracle = _parse_oracle(PYMEOS) + if oracle is None: + self.skipTest("PyMEOS oracle not available") + rep = build_parity( + _idl(["temporal_merge", "tnumber_integral"]), oracle) + self.assertEqual(rep["status"], "audited") + self.assertEqual(rep["needsCorrection"], 0, + [w for w in rep["worklist"] + if w["status"] == "needs-correction"]) + self.assertEqual(rep["aligned"], 18) # PyMEOS's 18 concrete classes + + +@unittest.skipUnless(_CATALOG.exists(), "run `python run.py` first") +class LiveParityGate(unittest.TestCase): + def test_live_no_divergence_unexplained(self): + cat = json.loads(_CATALOG.read_text()) + if "objectModel" not in cat: + self.skipTest("catalog has no objectModel") + rep = build_parity(cat, _parse_oracle(PYMEOS)) + # honest accounting: every divergence classified, none dropped + self.assertEqual(rep["knownCorrections"] + rep["needsCorrection"], + rep["divergences"]) + if rep["status"] == "audited": + self.assertEqual(rep["needsCorrection"], 0, + [w for w in rep["worklist"] + if w["status"] == "needs-correction"]) + + +if __name__ == "__main__": + unittest.main(verbosity=2)