From 6007903a131592343269cfecb19ad7105134d86f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 17:48:07 +0000 Subject: [PATCH] =?UTF-8?q?feat(redmine-canon):=20close=20the=20bridge=20?= =?UTF-8?q?=E2=80=94=2026/26=20convergence=20via=20engine-walk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-runs the OGAR producer through extract_app_with (OGAR#75, consuming ruff#28) so OpenProject's modules/* engines are harvested too. What changed: - fork_convergence.json bumped to schema "fork-convergence/2" — the new extractor includes modules/*/app/models for OpenProject (681 -> 922 lifted classes). - billable_work_entry (0x0103, the first cross-domain bridge — the same concept Odoo's account.analytic.line lands on in the commerce arm) now carries OpenProject's `TimeEntry` (modules/costs/app/models) alongside Redmine's `TimeEntry`. - shared_concepts 25 -> 26: every canonical concept Redmine contributes is now also contributed by OpenProject. Total convergence. - redmine.ogar.json provenance string updated to reflect the new extractor path (extract_app_with, engine-walking). Tests: - billable_work_entry_documents_the_modular_extraction_gap flipped to billable_work_entry_bridge_is_complete_both_forks: asserts the bridge is shared and both forks ship `TimeEntry`. - every_redmine_concept_has_an_openproject_witness pins the total convergence: no concept with a Redmine witness lacks an OP witness. - lower bound for shared concepts: 25 -> 26. - README: 25/26 -> all 26 shared, billable_work_entry row added with identical Redmine/OP names; gap note replaced with an explanation of where OP's TimeEntry sits and how the engine-walk closed it. cargo test 11/11; clippy clean; fmt clean. --- README.md | 40 +++++++------- .../redmine-canon/data/fork_convergence.json | 12 ++-- crates/redmine-canon/data/redmine.ogar.json | 2 +- crates/redmine-canon/src/convergence.rs | 55 +++++++++++++++---- 4 files changed, 70 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 430fc92..b906d19 100644 --- a/README.md +++ b/README.md @@ -93,29 +93,29 @@ shared `0x0103` identity is the first convergence invariant. `redmine-canon::convergence` carries a second artifact (`data/fork_convergence.json`) produced by running the OGAR producer over **both** real corpora (Redmine *and* OpenProject) and grouping by codebook -id. It is the executable proof of the shared-codebook claim: **25 of 26 -concepts are contributed by both forks, with identical ids** — even where the -Rails class names diverged across the lineage: - -| Canonical concept | Redmine | OpenProject | id | -|----------------------|----------------|---------------|----------| -| `project_work_item` | `Issue` | `WorkPackage` | `0x0102` | -| `project_status` | `IssueStatus` | `Status` | `0x0105` | -| `project_type` | `Tracker` | `Type` | `0x0106` | -| `project_forum` | `Board` | `Forum` | `0x0116` | -| `project_relation` | `IssueRelation`| `Relation` | `0x0111` | +id. It is the executable proof of the shared-codebook claim: **all 26 +concepts the Redmine corpus contributes are also contributed by OpenProject, +with identical ids** — even where the Rails class names diverged across the +lineage: + +| Canonical concept | Redmine | OpenProject | id | +|-----------------------|----------------|---------------|----------| +| `project_work_item` | `Issue` | `WorkPackage` | `0x0102` | +| `billable_work_entry` | `TimeEntry` | `TimeEntry` ✦ | `0x0103` | +| `project_status` | `IssueStatus` | `Status` | `0x0105` | +| `project_type` | `Tracker` | `Type` | `0x0106` | +| `project_forum` | `Board` | `Forum` | `0x0116` | +| `project_relation` | `IssueRelation`| `Relation` | `0x0111` | Different words, same node. That is the whole thesis. -### Known gap (honest) - -`billable_work_entry` (`0x0103`) currently shows **Redmine `TimeEntry` only**. -OpenProject's `TimeEntry` lives in `modules/costs/app/models/`, and the -extractor today walks **core `app/models/` only** — so ~311 modular-engine -models (TimeEntry, budgets, BIM, …) are not yet harvested. A test pins this -gap; closing it (walking `modules/*/app/models`) flips -`billable_work_entry` to shared and is the next producer improvement for the -OpenProject arm. +✦ OpenProject's `TimeEntry` lives in `modules/costs/app/models/` — invisible +to a core-only walk. The artifact now sources from the **engine-walking** +extractor (ruff#28 + OGAR#75: `extract_app_with`), so the producer harvests +core `app/models/` + every `modules/*/app/models/` (OpenProject grows from +681 → 922 lifted classes). With that change, `billable_work_entry` (the +first cross-domain bridge — Odoo's `account.analytic.line` lands here too in +the commerce arm) is now contributed by **both forks**. ## Build diff --git a/crates/redmine-canon/data/fork_convergence.json b/crates/redmine-canon/data/fork_convergence.json index 3cb54f5..b86e9b2 100644 --- a/crates/redmine-canon/data/fork_convergence.json +++ b/crates/redmine-canon/data/fork_convergence.json @@ -1,18 +1,18 @@ { - "schema_version": "fork-convergence/1", + "schema_version": "fork-convergence/2", "provenance": { - "extractor": "ogar-from-rails::extract_with → ruff_ruby_spo → ogar-from-ruff::lift_model_graph → ogar-vocab", + "extractor": "ogar-from-rails::extract_app_with → ruff_ruby_spo::extract_app_with (engine-walk) → ogar-from-ruff::lift_model_graph → ogar-vocab", "redmine_repo": "AdaWorldAPI/redmine", - "openproject_repo": "AdaWorldAPI/openproject (core app/models only)", + "openproject_repo": "AdaWorldAPI/openproject (core app/models + modules/*/app/models)", "generated": "2026-06-19" }, "forks": ["redmine", "openproject"], "lineage": "redmine -> chiliproject -> openproject", "redmine_total": 111, - "openproject_total": 681, - "shared_concepts": 25, + "openproject_total": 922, + "shared_concepts": 26, "concepts": [ - { "concept": "billable_work_entry", "class_id": "0x0103", "class_id_le": [3, 1], "redmine": ["TimeEntry"], "openproject": [] }, + { "concept": "billable_work_entry", "class_id": "0x0103", "class_id_le": [3, 1], "redmine": ["TimeEntry"], "openproject": ["TimeEntry"] }, { "concept": "priority", "class_id": "0x0107", "class_id_le": [7, 1], "redmine": ["IssuePriority"], "openproject": ["IssuePriority"] }, { "concept": "project", "class_id": "0x0101", "class_id_le": [1, 1], "redmine": ["Project"], "openproject": ["Project"] }, { "concept": "project_actor", "class_id": "0x0104", "class_id_le": [4, 1], "redmine": ["Group", "Principal", "User"], "openproject": ["Group", "Principal", "User"] }, diff --git a/crates/redmine-canon/data/redmine.ogar.json b/crates/redmine-canon/data/redmine.ogar.json index bb5bc6c..e6cb497 100644 --- a/crates/redmine-canon/data/redmine.ogar.json +++ b/crates/redmine-canon/data/redmine.ogar.json @@ -4,7 +4,7 @@ "source_curator": "redmine", "source_repo": "AdaWorldAPI/redmine", "source_domain": "project", - "extractor": "ogar-from-rails::extract_with → ruff_ruby_spo → ogar-from-ruff::lift_model_graph → ogar-vocab", + "extractor": "ogar-from-rails::extract_app_with → ruff_ruby_spo::extract_app_with (engine-walk) → ogar-from-ruff::lift_model_graph → ogar-vocab", "ogar_codebook": "AdaWorldAPI/OGAR ogar-vocab CODEBOOK (0xDDCC domain-encoded)", "generated": "2026-06-19" }, diff --git a/crates/redmine-canon/src/convergence.rs b/crates/redmine-canon/src/convergence.rs index 07d2992..ce3e788 100644 --- a/crates/redmine-canon/src/convergence.rs +++ b/crates/redmine-canon/src/convergence.rs @@ -45,7 +45,8 @@ impl ConvergedConcept { /// The full cross-fork convergence report. #[derive(Debug, Clone, Deserialize)] pub struct ForkConvergence { - /// Schema version tag (`"fork-convergence/1"`). + /// Schema version tag (`"fork-convergence/2"` since the engine-walking + /// extractor landed; `"fork-convergence/1"` was the core-only walk). pub schema_version: String, /// The two forks compared (`["redmine", "openproject"]`). pub forks: Vec, @@ -53,7 +54,8 @@ pub struct ForkConvergence { pub lineage: String, /// Total classes extracted from Redmine. pub redmine_total: usize, - /// Total classes extracted from OpenProject (core `app/models` only). + /// Total classes extracted from OpenProject (core `app/models` + + /// every `modules/*/app/models` engine, since `fork-convergence/2`). pub openproject_total: usize, /// Count of concepts both forks contribute to. pub shared_concepts: usize, @@ -87,7 +89,7 @@ mod tests { let fc = ForkConvergence::load(); let shared: Vec<&ConvergedConcept> = fc.concepts.iter().filter(|c| c.shared()).collect(); assert_eq!(shared.len(), fc.shared_concepts); - assert!(shared.len() >= 25, "expected the fork overlap to be broad"); + assert!(shared.len() >= 26, "expected the fork overlap to be broad"); for c in &shared { assert_eq!( c.class_id_le[1], 0x01, @@ -153,19 +155,48 @@ mod tests { } #[test] - fn billable_work_entry_documents_the_modular_extraction_gap() { - // HONEST gap: OpenProject's TimeEntry lives in - // modules/costs/app/models, which the current extractor (core - // app/models only) does not walk — so OP's side of this bridge is - // empty today, while Redmine's TimeEntry IS harvested. Pinning the - // gap makes closing it (walking modules/*/app/models) visible: this - // concept flips to shared and the assertion below must be updated. + fn billable_work_entry_bridge_is_complete_both_forks() { + // The modular extraction gap is CLOSED (ruff#28 + OGAR#75 + // extract_app_with): OpenProject's `TimeEntry` lives in + // modules/costs/app/models and is now harvested. Both forks ship a + // class literally named `TimeEntry` that lifts onto the same + // canonical concept and id — a clean, name-identical convergence on + // the first cross-domain bridge (the concept Odoo's + // account.analytic.line also lands on in the commerce arm). let fc = ForkConvergence::load(); let bridge = fc.concept("billable_work_entry").unwrap(); assert!(bridge.redmine.contains(&"TimeEntry".to_string())); assert!( - bridge.openproject.is_empty(), - "the modular extraction gap closed — promote billable_work_entry to a shared assertion" + bridge.openproject.contains(&"TimeEntry".to_string()), + "OpenProject TimeEntry must be harvested (extract_app_with)" ); + assert!( + bridge.shared(), + "billable_work_entry must be shared by both forks" + ); + assert_eq!(bridge.class_id_u16(), 0x0103); + } + + #[test] + fn every_redmine_concept_has_an_openproject_witness() { + // The headline: with engine-walking, EVERY canonical concept the + // Redmine corpus contributes is also contributed by OpenProject. + // Convergence is total at the project-mgmt level — no Redmine-only + // promoted concept remains. + let fc = ForkConvergence::load(); + for c in &fc.concepts { + if !c.redmine.is_empty() { + assert!( + !c.openproject.is_empty(), + "{} has Redmine witness but no OpenProject witness ({:?})", + c.concept, + c.redmine, + ); + } + } + // Same statement in counts: every concept-with-a-Redmine-side has an + // OpenProject side too. + let redmine_concepts = fc.concepts.iter().filter(|c| !c.redmine.is_empty()).count(); + assert_eq!(redmine_concepts, fc.shared_concepts); } }