From c233222a5bde3792733f487e8f99f64e38a88a87 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 05:02:32 +0000 Subject: [PATCH] render_osm/render_python: merge colliding rail tiles; CLASS_ID as ClassVar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the review on #154 (merged), fixing two emitter defects: - Colliding is_a → dropped tile (Bugbot Medium). When two distinct is_a rail keys normalise to the same emitted identifier (`foo?` and `foo` both → `foo`), the loop `continue`-skipped the second and lost the tile. Both emitters now GROUP by the emitted name and MERGE the colliding keys' sources + labels into one stub (all cited), so no rail tile silently vanishes. The Rust render_osm had the same latent drop; fixed in lockstep. - CLASS_ID as ClassVar (codex P2). The Python model emitted `CLASS_ID: int`, a dataclass INSTANCE field — so it entered __init__/repr/eq and `Node(5)` would set CLASS_ID=5. Now `CLASS_ID: ClassVar[int]` (+ ClassVar import): a stable per-class identity, not mutable record data. Regenerated both snapshots (source re-cloned): 50 models + 318 DO-arm fns unchanged (no collisions in the OSM corpus today — the fix is against the latent case). Verified: osm-domain builds; py_compile green; CLASS_ID no longer appears in dataclasses.fields(Node); re-exported osm.nodes.show callable. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EYvNjD8M8LMNYbRy3gq2FP --- .../osm-website-rs/python/osm/models.py | 42 +++++++++---------- .../ogar-render-askama/examples/render_osm.rs | 32 ++++++++------ .../examples/render_python.rs | 26 +++++++----- 3 files changed, 56 insertions(+), 44 deletions(-) diff --git a/.claude/harvest/osm-website-rs/python/osm/models.py b/.claude/harvest/osm-website-rs/python/osm/models.py index 7ff58eb..4ca407d 100644 --- a/.claude/harvest/osm-website-rs/python/osm/models.py +++ b/.claude/harvest/osm-website-rs/python/osm/models.py @@ -3,7 +3,7 @@ """ from __future__ import annotations from dataclasses import dataclass, field -from typing import Any, List, Optional +from typing import Any, ClassVar, List, Optional @dataclass class Acl: @@ -15,7 +15,7 @@ class ApplicationRecord: @dataclass class Changeset: - CLASS_ID: int = 0x0F04 # canonical concept `osm_changeset` + CLASS_ID: ClassVar[int] = 0x0F04 # canonical concept `osm_changeset` user: Optional[int] = None changeset_tags: List[int] = field(default_factory=list) nodes: List[int] = field(default_factory=list) @@ -104,7 +104,7 @@ class ModerationZone: @dataclass class Node: - CLASS_ID: int = 0x0F01 # canonical concept `osm_node` + CLASS_ID: ClassVar[int] = 0x0F01 # canonical concept `osm_node` changeset: Optional[int] = None old_nodes: List[int] = field(default_factory=list) way_nodes: List[int] = field(default_factory=list) @@ -117,12 +117,12 @@ class Node: @dataclass class NodeTag: - CLASS_ID: int = 0x0F05 # canonical concept `osm_element_tag` + CLASS_ID: ClassVar[int] = 0x0F05 # canonical concept `osm_element_tag` node: Optional[int] = None @dataclass class Note: - CLASS_ID: int = 0x0F08 # canonical concept `osm_note` + CLASS_ID: ClassVar[int] = 0x0F08 # canonical concept `osm_note` author: Optional[int] = None comments: List[int] = field(default_factory=list) all_comments: List[int] = field(default_factory=list) @@ -145,7 +145,7 @@ class Oauth2Application: @dataclass class OldNode: - CLASS_ID: int = 0x0F01 # canonical concept `osm_node` + CLASS_ID: ClassVar[int] = 0x0F01 # canonical concept `osm_node` changeset: Optional[int] = None redaction: Optional[int] = None current_node: Optional[int] = None @@ -153,12 +153,12 @@ class OldNode: @dataclass class OldNodeTag: - CLASS_ID: int = 0x0F05 # canonical concept `osm_element_tag` + CLASS_ID: ClassVar[int] = 0x0F05 # canonical concept `osm_element_tag` old_node: Optional[int] = None @dataclass class OldRelation: - CLASS_ID: int = 0x0F03 # canonical concept `osm_relation` + CLASS_ID: ClassVar[int] = 0x0F03 # canonical concept `osm_relation` changeset: Optional[int] = None redaction: Optional[int] = None current_relation: Optional[int] = None @@ -167,18 +167,18 @@ class OldRelation: @dataclass class OldRelationMember: - CLASS_ID: int = 0x0F06 # canonical concept `osm_relation_member` + CLASS_ID: ClassVar[int] = 0x0F06 # canonical concept `osm_relation_member` old_relation: Optional[int] = None member: Optional[int] = None @dataclass class OldRelationTag: - CLASS_ID: int = 0x0F05 # canonical concept `osm_element_tag` + CLASS_ID: ClassVar[int] = 0x0F05 # canonical concept `osm_element_tag` old_relation: Optional[int] = None @dataclass class OldWay: - CLASS_ID: int = 0x0F02 # canonical concept `osm_way` + CLASS_ID: ClassVar[int] = 0x0F02 # canonical concept `osm_way` changeset: Optional[int] = None redaction: Optional[int] = None current_way: Optional[int] = None @@ -187,14 +187,14 @@ class OldWay: @dataclass class OldWayNode: - CLASS_ID: int = 0x0F07 # canonical concept `osm_way_node` + CLASS_ID: ClassVar[int] = 0x0F07 # canonical concept `osm_way_node` old_way: Optional[int] = None node: Optional[int] = None way: Optional[int] = None @dataclass class OldWayTag: - CLASS_ID: int = 0x0F05 # canonical concept `osm_element_tag` + CLASS_ID: ClassVar[int] = 0x0F05 # canonical concept `osm_element_tag` old_way: Optional[int] = None @dataclass @@ -206,7 +206,7 @@ class Redaction: @dataclass class Relation: - CLASS_ID: int = 0x0F03 # canonical concept `osm_relation` + CLASS_ID: ClassVar[int] = 0x0F03 # canonical concept `osm_relation` changeset: Optional[int] = None old_relations: List[int] = field(default_factory=list) relation_members: List[int] = field(default_factory=list) @@ -216,13 +216,13 @@ class Relation: @dataclass class RelationMember: - CLASS_ID: int = 0x0F06 # canonical concept `osm_relation_member` + CLASS_ID: ClassVar[int] = 0x0F06 # canonical concept `osm_relation_member` relation: Optional[int] = None member: Optional[int] = None @dataclass class RelationTag: - CLASS_ID: int = 0x0F05 # canonical concept `osm_element_tag` + CLASS_ID: ClassVar[int] = 0x0F05 # canonical concept `osm_element_tag` relation: Optional[int] = None @dataclass @@ -240,7 +240,7 @@ class SpammyPhrase: @dataclass class Trace: - CLASS_ID: int = 0x0F09 # canonical concept `osm_gpx_trace` + CLASS_ID: ClassVar[int] = 0x0F09 # canonical concept `osm_gpx_trace` user: Optional[int] = None tags: List[int] = field(default_factory=list) points: List[int] = field(default_factory=list) @@ -255,7 +255,7 @@ class Tracetag: @dataclass class User: - CLASS_ID: int = 0x0F0A # canonical concept `osm_user` + CLASS_ID: ClassVar[int] = 0x0F0A # canonical concept `osm_user` traces: List[int] = field(default_factory=list) diary_entries: List[int] = field(default_factory=list) diary_comments: List[int] = field(default_factory=list) @@ -315,7 +315,7 @@ class UserRole: @dataclass class Way: - CLASS_ID: int = 0x0F02 # canonical concept `osm_way` + CLASS_ID: ClassVar[int] = 0x0F02 # canonical concept `osm_way` changeset: Optional[int] = None old_ways: List[int] = field(default_factory=list) way_nodes: List[int] = field(default_factory=list) @@ -326,12 +326,12 @@ class Way: @dataclass class WayNode: - CLASS_ID: int = 0x0F07 # canonical concept `osm_way_node` + CLASS_ID: ClassVar[int] = 0x0F07 # canonical concept `osm_way_node` way: Optional[int] = None node: Optional[int] = None @dataclass class WayTag: - CLASS_ID: int = 0x0F05 # canonical concept `osm_element_tag` + CLASS_ID: ClassVar[int] = 0x0F05 # canonical concept `osm_element_tag` way: Optional[int] = None diff --git a/crates/ogar-render-askama/examples/render_osm.rs b/crates/ogar-render-askama/examples/render_osm.rs index a6314bb..6e9a669 100644 --- a/crates/ogar-render-askama/examples/render_osm.rs +++ b/crates/ogar-render-askama/examples/render_osm.rs @@ -288,28 +288,34 @@ fn main() { "pub mod {} {{\n use super::{{Input, Output}};\n", rustify(part_of) )); - let mut seen = std::collections::HashSet::new(); + // Distinct is_a rail keys can normalise to the SAME Rust fn name (`foo?` + // and `foo` both → `foo`). Group by the emitted name and MERGE their + // sources + labels into one fn, so no rail tile silently vanishes + // (a `continue` here dropped the collider — codex/Bugbot on the Python + // sibling #154; the Rust emitter had the same latent drop). + let mut by_fname: std::collections::BTreeMap, Vec)> = + std::collections::BTreeMap::new(); for (is_a, sources) in isas { - let fname = rustify(is_a); - if !seen.insert(fname.clone()) { - continue; - } - // All source controllers that collapse onto this canonical tile, - // deduped + sorted, cited so no route is lost from the docs. - let mut srcs: Vec = sources - .iter() - .map(|(c, a)| format!("{c}#{a}")) - .collect(); + let entry = by_fname.entry(rustify(is_a)).or_default(); + entry.0.push(is_a.clone()); + entry + .1 + .extend(sources.iter().map(|(c, a)| format!("{c}#{a}"))); + } + for (fname, (mut labels, mut srcs)) in by_fname { + labels.sort(); + labels.dedup(); srcs.sort(); srcs.dedup(); - let primary = &srcs[0]; + let is_a_disp = labels.join(", "); + let primary = srcs[0].clone(); let source_line = if srcs.len() == 1 { format!("Source: `{primary}`.") } else { format!("Sources (canonical tile): `{}`.", srcs.join("`, `")) }; acts.push_str(&format!( - " /// `{part_of}:{is_a}` — DO arm. {source_line}\n \ + " /// `{part_of}:{is_a_disp}` — DO arm. {source_line}\n \ pub fn {fname}(input: Input) -> Output {{\n \ let _ = input;\n todo!(\"port {primary}\")\n }}\n" )); diff --git a/crates/ogar-render-askama/examples/render_python.rs b/crates/ogar-render-askama/examples/render_python.rs index 0063aa6..531f3e4 100644 --- a/crates/ogar-render-askama/examples/render_python.rs +++ b/crates/ogar-render-askama/examples/render_python.rs @@ -127,7 +127,7 @@ fn main() { "\"\"\"@generated by ogar-render-askama render_python — ruff → OGAR (THINK arm).\n\ OSM domain models as dataclasses; associations become typed id fields.\n\"\"\"\n\ from __future__ import annotations\nfrom dataclasses import dataclass, field\n\ - from typing import Any, List, Optional\n\n", + from typing import Any, ClassVar, List, Optional\n\n", ); let mut class_ids: Vec<(String, u16)> = Vec::new(); for c in &classes { @@ -136,7 +136,7 @@ fn main() { if let Some(concept) = &c.canonical_concept { if let Some(id) = canonical_concept_id(concept) { models.push_str(&format!( - " CLASS_ID: int = 0x{id:04X} # canonical concept `{concept}`\n" + " CLASS_ID: ClassVar[int] = 0x{id:04X} # canonical concept `{concept}`\n" )); class_ids.push((cls.clone(), id)); } @@ -207,16 +207,22 @@ fn main() { Input = dict # the Rails params bag; typed params are the next ruff brick\n\ Output = Any\n\n", ); - let mut seen = std::collections::HashSet::new(); + // Distinct is_a rail keys can normalise to the SAME Python identifier + // (`foo?` and `foo` both → `foo`). Group by the emitted name and MERGE + // their sources + labels into one stub, so no rail tile silently + // vanishes (Bugbot #154). + let mut by_fname: BTreeMap, Vec)> = BTreeMap::new(); for (is_a, sources) in isas { - let fname = pyify(is_a); - if !seen.insert(fname.clone()) { - continue; - } - let mut srcs: Vec = - sources.iter().map(|(c, a)| format!("{c}#{a}")).collect(); + let entry = by_fname.entry(pyify(is_a)).or_default(); + entry.0.push(is_a.clone()); + entry.1.extend(sources.iter().map(|(c, a)| format!("{c}#{a}"))); + } + for (fname, (mut labels, mut srcs)) in by_fname { + labels.sort(); + labels.dedup(); srcs.sort(); srcs.dedup(); + let is_a_disp = labels.join(", "); let cite = if srcs.len() == 1 { format!("Source: {}", srcs[0]) } else { @@ -224,7 +230,7 @@ fn main() { }; body.push_str(&format!( "def {fname}(inp: Input) -> Output:\n \ - \"\"\"`{part_of}:{is_a}` — DO arm. {cite}.\"\"\"\n \ + \"\"\"`{part_of}:{is_a_disp}` — DO arm. {cite}.\"\"\"\n \ raise NotImplementedError(\"port {}\")\n\n", srcs[0] ));