diff --git a/crates/ogar-class-view/src/lib.rs b/crates/ogar-class-view/src/lib.rs index 077a68a9..20061af7 100644 --- a/crates/ogar-class-view/src/lib.rs +++ b/crates/ogar-class-view/src/lib.rs @@ -112,6 +112,7 @@ use ogar_vocab::{ osm_note, osm_relation, osm_relation_member, + osm_street_node, osm_user, osm_way, osm_way_node, @@ -266,6 +267,7 @@ fn all_canonical_classes() -> Vec<(&'static str, Class)> { ("osm_note", osm_note()), ("osm_gpx_trace", osm_gpx_trace()), ("osm_user", osm_user()), + ("osm_street_node", osm_street_node()), ] } diff --git a/crates/ogar-osm/src/lib.rs b/crates/ogar-osm/src/lib.rs index 33413d95..e7f8a2f3 100644 --- a/crates/ogar-osm/src/lib.rs +++ b/crates/ogar-osm/src/lib.rs @@ -92,13 +92,34 @@ pub const fn classid(concept: u16, classview: u16) -> u32 { render_classid(classview, concept) } +/// Concepts DERIVED by a bake rather than mirrored from a Rails class. +/// +/// The Geo domain began as a mirror of OSM's own schema, so "every concept +/// grounds to a Rails class" held by construction. It stopped holding when the +/// bake started synthesizing junctions from the routable-way graph: OSM has no +/// junction element, and inventing a Rails name to satisfy the check would put +/// a falsehood in the Core to keep a test green. +/// +/// Membership is therefore an explicit, reviewable list — not a relaxed +/// assertion. Adding a name here is a claim that OSM genuinely has no source +/// element for it. +pub const SYNTHESIZED: &[&str] = &["osm_street_node"]; + /// Every Geo concept, in codebook order, with its Rails source class(es). /// /// The `Old*` versioned Rails classes ground to their base concept — the /// temporal axis is a Lance version, not a new id — which is why `Node` and /// `OldNode` share `0x0F01`. +/// +/// One entry has NO Rails source: see [`SYNTHESIZED`]. pub const OSM_CONCEPTS: &[(&str, u16, &[&str])] = &[ ("osm_node", class_ids::OSM_NODE, &["Node", "OldNode"]), + // The one DERIVED concept: OSM has no junction element, so there is no + // Rails class to ground against. The bake synthesizes it from the + // routable-way graph. Its rails list is deliberately empty — see + // `tests::every_concept_sits_in_the_geo_domain`, which allows exactly + // this concept to have none rather than accepting a fabricated source. + ("osm_street_node", class_ids::OSM_STREET_NODE, &[]), ("osm_way", class_ids::OSM_WAY, &["Way", "OldWay"]), ( "osm_relation", @@ -556,15 +577,29 @@ mod tests { GEO_DOMAIN, "{name} ({id:#06x}) is outside the Geo domain" ); - assert!(!rails.is_empty(), "{name} has no Rails source class"); + // Empty rails is allowed for EXACTLY the concepts OSM does not + // have an element for — a derived concept has nothing to ground + // against, and a fabricated source class would be worse than an + // honest absence. Anything else with no rails is a mistake. + if !SYNTHESIZED.contains(name) { + assert!(!rails.is_empty(), "{name} has no Rails source class"); + } + } + // Every synthesized name must actually BE a concept — otherwise the + // exemption above silently covers a typo and re-opens the hole. + for s in SYNTHESIZED { + assert!( + OSM_CONCEPTS.iter().any(|(n, _, _)| n == s), + "{s} is exempted but is not a concept" + ); } - // Ten concepts, and the ids are distinct — a copy-paste that reused + // Eleven concepts, and the ids are distinct — a copy-paste that reused // one constant twice would pass a domain check but fail here. - assert_eq!(OSM_CONCEPTS.len(), 10); + assert_eq!(OSM_CONCEPTS.len(), 11); let mut ids: Vec = OSM_CONCEPTS.iter().map(|(_, id, _)| *id).collect(); ids.sort_unstable(); ids.dedup(); - assert_eq!(ids.len(), 10, "two concepts share an id"); + assert_eq!(ids.len(), 11, "two concepts share an id"); } #[test] diff --git a/crates/ogar-vocab/src/capability_registry.rs b/crates/ogar-vocab/src/capability_registry.rs index 44df86bf..3dff13fa 100644 --- a/crates/ogar-vocab/src/capability_registry.rs +++ b/crates/ogar-vocab/src/capability_registry.rs @@ -372,7 +372,7 @@ fn resolve_concept_row(id: u16) -> Option<(&'static str, u16)> { mod the_canon_carries_no_palette_rows { #[test] fn no_0x17xx_row_reached_the_globally_mirrored_codebook() { - assert_eq!(crate::class_ids::ALL.len(), 90); + assert_eq!(crate::class_ids::ALL.len(), 91); for (_, id) in crate::class_ids::ALL { assert_ne!(*id >> 8, 0x17, "a 0x17XX row reached the codebook"); } diff --git a/crates/ogar-vocab/src/lib.rs b/crates/ogar-vocab/src/lib.rs index d83b2c9e..cdc8f54f 100644 --- a/crates/ogar-vocab/src/lib.rs +++ b/crates/ogar-vocab/src/lib.rs @@ -1381,6 +1381,14 @@ const CODEBOOK: &[(&str, u16)] = &[ ("osm_note", 0x0F08), ("osm_gpx_trace", 0x0F09), ("osm_user", 0x0F0A), + // SYNTHESIZED — the one Geo concept with no Rails source. OSM has no + // "junction" element: a street node is DERIVED by the bake from the + // routable-way graph (Berlin 619,470 junction nodes → 233,053 rows; + // Brandenburg 590,437). It earns its own id because its VALUE SLOTS mean + // something different from an `osm_node`'s — per-edge names, adjacency, + // bearing, and a turn matrix, resolved by ClassView — and there is + // otherwise no byte on disk distinguishing the two readings. + ("osm_street_node", 0x0F0B), ]; /// Codebook **domain** — the high byte of a canonical id (see @@ -1985,6 +1993,20 @@ pub mod class_ids { pub const OSM_GPX_TRACE: u16 = 0x0F09; /// `osm_user` (`0x0F0A`) — a mapper account. Rails `User`. pub const OSM_USER: u16 = 0x0F0A; + /// `osm_street_node` (`0x0F0B`) — a junction in the routable-way graph. + /// + /// **No Rails source, by nature**: OSM has no junction element. This is + /// DERIVED by the bake wherever routable ways meet, and it exists as its + /// own concept because its value slots carry a different schema from an + /// ordinary `osm_node` — per-edge street names, adjacency, bearing and a + /// turn matrix, all resolved through its ClassView. + /// + /// Without a distinct classid there is no byte on disk separating "a + /// junction row using its edge slots" from "a tagged node using its first + /// tag slot" — a reader that guessed measured **1,084,213 false hits** on + /// real Berlin data (see `osm-soa-bake`'s `street.rs`). The id IS the + /// disambiguator. + pub const OSM_STREET_NODE: u16 = 0x0F0B; // ── 0x07XX — OSINT domain: no concept constants (low byte = APPID, // domain-wise; q2 = 0x01 → `0x0701` is OSINT-for-q2, not a concept — @@ -2096,6 +2118,7 @@ pub mod class_ids { ("osm_note", OSM_NOTE), ("osm_gpx_trace", OSM_GPX_TRACE), ("osm_user", OSM_USER), + ("osm_street_node", OSM_STREET_NODE), ]; #[cfg(test)] @@ -2159,7 +2182,7 @@ pub mod class_ids { // lance-graph mirror is rebuilt against it. assert_eq!( ALL.len(), - 90, + 91, "class_ids::ALL count changed — update this pin AND the \ lance-graph mirror COUNT_FUSE (crates/lance-graph-ogar/src/lib.rs) \ in the same PR", @@ -3045,6 +3068,7 @@ pub fn all_promoted_classes() -> Vec { osm_note(), osm_gpx_trace(), osm_user(), + osm_street_node(), ] } @@ -4761,6 +4785,25 @@ pub fn osm_user() -> Class { c } +/// The `osm_street_node` (`0x0F0B`) — a junction in the routable-way graph. +/// +/// The one Geo class with **no Rails source**: OSM has no junction element, so +/// `Class::new` names the derived concept rather than a mirrored table. It is +/// synthesized by the bake wherever routable ways meet, and it exists as its +/// own class because its value slots carry a schema an `osm_node`'s do not — +/// per-edge street names, adjacency, bearing and a turn matrix. +/// +/// Its association is to `Way`, not to `Node`: a junction is defined BY the +/// ways that meet at it, which is also how the bake derives it. +#[must_use] +pub fn osm_street_node() -> Class { + let mut c = Class::new("StreetNode"); + c.language = Language::Unknown; + c.canonical_concept = Some("osm_street_node".to_string()); + c.associations = vec![family_edge("ways", "Way")]; + c +} + // ── 0x0CXX — Automation domain builders (HIRO IT-automation stack) ── // The MARS structural CMDB (A→R→S→M `dependsOn` backbone) + the Automation // DO-arm actuators. Shapes grounded in the vendored OGIT TTL attributes