Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 20 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 6 additions & 6 deletions crates/redmine-canon/data/fork_convergence.json
Original file line number Diff line number Diff line change
@@ -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"] },
Expand Down
2 changes: 1 addition & 1 deletion crates/redmine-canon/data/redmine.ogar.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
55 changes: 43 additions & 12 deletions crates/redmine-canon/src/convergence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,15 +45,17 @@ 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<String>,
/// Human-readable lineage chain.
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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
}
}
Loading