From f44f712b06533426a60893b3728cc937b19e55a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 22:06:51 +0000 Subject: [PATCH 1/4] test(schema): nine-domain round-trip + authorship provenance check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the lift-surface verification from MARS-only to 10 domains: MARS + Transport + Accounting + SalesDistribution + Credit + Cost + ServiceManagement + WorkOrder + Compliance + Audit. Coverage: * 210 TTL files across 9 new domains (in addition to MARS's 29) — all parse, all round-trip via the generic `assert_domain_roundtrip` helper. Zero unparseable shapes; zero round-trip failures. * `ttl_emit::tests::nine_domains_lift_surface_round_trip` is the permanent regression gate. Pins each domain's expected TTL count so an upstream re-vendor that drifts the inventory fires the test. Authorship verification (per the operator's suggestion to check `dcterms:creator`): * WorkOrder is FULLY OURS — sole authors are internal agent names (`bus-compiler`, `family-codec-smith`). The unusual `rdfs:Class`-as-verb convention is ours to revise toward `owl:ObjectProperty` without external coordination. * Accounting is MIXED-AUTHORSHIP — Viktor Voss (23 files, original arago) + a prior session's Claude extension (11 files, our local additions). * All other 7 domains are pure-upstream (single external human authors: chris.boos@almato.com, Marek Meyer, Peter Larem, Ola Irgens Kylling, Aymen Ayoub, …). This makes WorkOrder the natural prototyping ground for new TTL predicates OGAR wants to ship before pitching them to OGIT upstream. Doc updates: * `docs/OGIT-DOMAIN-LIFT-CATALOGUE.md` — 10 rows promoted to Lift-tested with per-row authorship provenance. Adds a verification recipe (`§ Verifying domain authorship`) so future sessions can re-run the `dcterms:creator` scan in one Python heredoc. * `.claude/board/EPIPHANIES.md` — new FINDING on author-provenance as a who-can-change-what discriminator. Test footprint: 16/16 in ogar-from-schema (was 15/15; the new test adds one). Workspace-wide: nothing else touched. --- .claude/board/EPIPHANIES.md | 40 ++++++++++ crates/ogar-from-schema/src/ttl_emit.rs | 98 +++++++++++++++++++++++-- docs/OGIT-DOMAIN-LIFT-CATALOGUE.md | 56 +++++++++++--- 3 files changed, 175 insertions(+), 19 deletions(-) diff --git a/.claude/board/EPIPHANIES.md b/.claude/board/EPIPHANIES.md index 19276a4..a114036 100644 --- a/.claude/board/EPIPHANIES.md +++ b/.claude/board/EPIPHANIES.md @@ -15,6 +15,46 @@ ## Entries (newest first) +## 2026-06-22 — Author provenance via `dcterms:creator` discriminates "ours to revise" from "upstream-coordinated" +**Status:** FINDING +**Scope:** OGIT NTO governance × multi-domain lift × who-can-change-what + +OGIT TTL files carry `dcterms:creator` on every subject. The field is +free-form text but carries one of two semantic shapes in practice: + +- **Human author + email** (`chris.boos@almato.com`, `Viktor Voss`, + `fotto@arago.de`, `Marek Meyer`, `Peter Larem`, `Ola Irgens Kylling`, + …) — original arago/almato authors. Structural changes need upstream + coordination. +- **Internal agent name** (`bus-compiler`, `family-codec-smith`, + `Claude (AdaWorldAPI/lance-graph 3-hop optim)`, …) — files authored + by our agent fleet against this org's forks. We are upstream for + these; structural changes need no external coordination. + +The 9-domain spot check (Transport, Accounting, SalesDistribution, +Credit, Cost, ServiceManagement, WorkOrder, Compliance, Audit) revealed: + +- **WorkOrder is fully ours** — 100% internal-agent authorship (`bus-compiler`, + `family-codec-smith`). The unusual `rdfs:Class`-as-verb convention is + ours to revise toward standard `owl:ObjectProperty`-as-verb whenever + the AST predicate registry needs the WorkOrder verbs. +- **Accounting is mixed-authorship** — Viktor Voss (23 files, original) + + a prior session's `Claude` extension (11 files). Structural changes + to the original 23 require upstream coordination; the 11 are ours. +- **All other 7 domains are pure-upstream** — single-or-few external + human authors. + +This makes WorkOrder the **natural prototyping ground** for new TTL +predicates OGAR wants to add: ship in WorkOrder first (no external +coordination cost), validate the bijection, then pitch the pattern +to OGIT upstream once it's proven. + +Evidence: the `dcterms:creator` provenance scan recipe lives in +`docs/OGIT-DOMAIN-LIFT-CATALOGUE.md § Verifying domain authorship`; +the round-trip stress test for the 9 domains is +`ttl_emit::tests::nine_domains_lift_surface_round_trip` (zero failures +on 210 TTLs across the nine). + ## 2026-06-22 — Schema-vs-source duality: schemas lift structure bijectively; source ASTs lift behaviour best-effort; they cross-validate at the structural boundary **Status:** FINDING **Scope:** producer architecture × MARS calibration × Foundry-Odoo lens × the bardioc migration diff --git a/crates/ogar-from-schema/src/ttl_emit.rs b/crates/ogar-from-schema/src/ttl_emit.rs index 9fc1da7..3960362 100644 --- a/crates/ogar-from-schema/src/ttl_emit.rs +++ b/crates/ogar-from-schema/src/ttl_emit.rs @@ -276,20 +276,102 @@ mod tests { /// predicate. #[test] fn all_mars_ttl_files_roundtrip() { + let stats = assert_domain_roundtrip("MARS"); + // 29 .ttl files in NTO/MARS at the SHA pinned by PROVENANCE.md. + assert!( + stats.total >= 29, + "expected ≥ 29 TTL files in MARS, got {}", + stats.total + ); + } + + /// Generic helper that walks `vocab/imports/ogit/NTO//`, + /// dispatches each TTL to the right parser (`parse_file` for entities + /// and datatype attributes, `crate::sgo::parse_verb` for in-domain + /// `owl:ObjectProperty` verbs), and asserts semantic round-trip. + /// Returns per-shape counts so callers can sanity-check the lift + /// surface they're claiming. + fn assert_domain_roundtrip(domain: &str) -> DomainStats { let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../../vocab/imports/ogit/NTO/MARS"); - let mut checked = 0usize; + .join("../../vocab/imports/ogit/NTO") + .join(domain); + let mut stats = DomainStats::default(); for entry in walk_ttl(&dir) { let src = std::fs::read_to_string(&entry).expect("read"); + stats.total += 1; match parse_file(&src) { - Some(TtlDeclaration::Entity(_)) => assert_entity_roundtrip(&src), - Some(TtlDeclaration::DatatypeAttribute(_)) => assert_attribute_roundtrip(&src), - None => {} + Some(TtlDeclaration::Entity(_)) => { + assert_entity_roundtrip(&src); + stats.entities += 1; + } + Some(TtlDeclaration::DatatypeAttribute(_)) => { + assert_attribute_roundtrip(&src); + stats.attributes += 1; + } + None => { + // Try the verb path — some NTO domains carry their own + // in-domain `owl:ObjectProperty` verbs (Transport, + // Accounting, Credit, Compliance) alongside SGO's + // upstream-shared vocabulary. + let Some(once) = crate::sgo::parse_verb(&src) else { + panic!( + "TTL has no recognised subject type in {domain}: {}", + entry.display() + ); + }; + let emitted = crate::sgo::emit_verb(&once); + let twice = crate::sgo::parse_verb(&emitted).expect("re-parse verb"); + assert_eq!( + once, + twice, + "verb round-trip lost a predicate in {domain}: {}", + entry.display() + ); + stats.verbs += 1; + } } - checked += 1; } - // 29 .ttl files in NTO/MARS at the SHA pinned by PROVENANCE.md. - assert!(checked >= 29, "expected ≥ 29 TTL files, got {checked}"); + stats + } + + #[derive(Debug, Default)] + struct DomainStats { + total: usize, + entities: usize, + attributes: usize, + verbs: usize, + } + + /// Cross-domain bijection coverage. Each row is one of the nine + /// domains the operator asked OGAR to verify before promoting + /// the lift surface from MARS-only to multi-domain. If any of + /// these fails, the producer can't land on that domain without + /// extending `EntityDecl` / `AttributeDecl` / `VerbDecl` first. + /// + /// Counts are also a sanity check on the inventory — they prove + /// the catalogue's per-domain numbers match what's actually in + /// `vocab/imports/`. + #[test] + fn nine_domains_lift_surface_round_trip() { + for (domain, expected_total) in [ + ("Transport", 27), + ("Accounting", 36), + ("SalesDistribution", 23), + ("Credit", 21), + ("Cost", 5), + ("ServiceManagement", 59), + ("WorkOrder", 27), + ("Compliance", 9), + ("Audit", 3), + ] { + let stats = assert_domain_roundtrip(domain); + assert_eq!( + stats.total, expected_total, + "{domain}: TTL count drifted from inventory \ + (expected {expected_total}, got {})", + stats.total + ); + } } fn walk_ttl(root: &std::path::Path) -> Vec { diff --git a/docs/OGIT-DOMAIN-LIFT-CATALOGUE.md b/docs/OGIT-DOMAIN-LIFT-CATALOGUE.md index 5f81364..09440a0 100644 --- a/docs/OGIT-DOMAIN-LIFT-CATALOGUE.md +++ b/docs/OGIT-DOMAIN-LIFT-CATALOGUE.md @@ -17,12 +17,46 @@ | **Production** | A consumer deployment exercises the lifted form (see `DOMAIN-INSTANCES.md`) | A domain advances Imported → Lift-tested → Cross-walked → Production -left-to-right. All 72 are Imported today (just landed). MARS is the -first Lift-tested. OpenProject/Odoo/Healthcare are Production via the +left-to-right. All 72 are Imported today (just landed). The following +**10 domains are Lift-tested** (round-trip mechanically enforced by +`ttl_emit::tests::nine_domains_lift_surface_round_trip` + +`all_mars_ttl_files_roundtrip`): MARS, Transport, Accounting, +SalesDistribution, Credit, Cost, ServiceManagement, WorkOrder, +Compliance, Audit. OpenProject/Odoo/Healthcare are Production via the existing canonical concept work but use the source-AST lift, not the schema lift — they enter Lift-tested when their TTLs are added to the round-trip stress test. +## Verifying domain authorship (who can change what) + +Provenance is `dcterms:creator` on each TTL. Run: + +```bash +python3 - <<'PY' +import os, re +from collections import Counter +creator_re = re.compile(r'dcterms:creator\s+"([^"]+)"') +for d in sorted(os.listdir('vocab/imports/ogit/NTO')): + root = f'vocab/imports/ogit/NTO/{d}' + authors = Counter() + for r,_,fs in os.walk(root): + for f in fs: + if not f.endswith('.ttl'): continue + with open(os.path.join(r,f)) as fh: + for m in creator_re.finditer(fh.read()): + authors[m.group(1)] += 1 + if not authors: continue + top = ', '.join(f'{a} ({c})' for a,c in authors.most_common(5)) + print(f'{d:<28} {top}') +PY +``` + +Internal-agent authors (`bus-compiler`, `family-codec-smith`, `Claude +(...)`, etc.) signal "our extension — we can revise without external +coordination." External authors (`chris.boos@almato.com`, `Viktor Voss`, +`fotto@arago.de`, …) signal "upstream-owned — structural changes need +arago/almato coordination." + ## How to add a new domain to the lift 1. **Verify import** — `ls vocab/imports/ogit/NTO//`. If @@ -44,16 +78,16 @@ round-trip stress test. | Domain | Entities | Attributes | Verbs | Status | Notes | |---|--:|--:|--:|---|---| -| `Accounting` | 9 | 20 | 7 | Imported | Covered conceptually via `0x02XX` commerce/ERP via Odoo lift | +| `Accounting` | 9 | 20 | 7 | Lift-tested | Mixed-authorship: `Viktor Voss` (23 files, original arago/almato) + a prior session's extension (`Claude (AdaWorldAPI/lance-graph 3-hop optim)`, 11 files). Covered conceptually via `0x02XX` commerce/ERP via Odoo lift. Structural changes to the original 23 need upstream coordination; the 11 extensions are ours. | | `Advertising` | 16 | 0 | 0 | Imported | | -| `Audit` | 3 | 0 | 0 | Imported | Audit-as-Lance-version (ADR-013) covers the semantics | +| `Audit` | 3 | 0 | 0 | Lift-tested | `Marek Meyer` (sole author) — pure upstream. Audit-as-Lance-version (ADR-013) covers the semantics. | | `Auth` | 13 | 24 | 6 | Imported | Cross-walk to `0x0BXX` auth domain (Zitadel/Zanzibar) queued | | `Automation` | 22 | 105 | 0 | Imported | OLD `marsNodeType` superseded by `NTO/MARS/` | | `Botany` | 2 | 0 | 0 | Imported | | | `ClassificationStandard` | 2 | 5 | 2 | Imported | | -| `Compliance` | 1 | 4 | 4 | Imported | | -| `Cost` | 5 | 0 | 0 | Imported | | -| `Credit` | 12 | 0 | 9 | Imported | | +| `Compliance` | 1 | 4 | 4 | Lift-tested | `chris.boos@almato.com` (sole author) — pure upstream | +| `Cost` | 5 | 0 | 0 | Lift-tested | `Peter Larem` (sole author) — pure upstream | +| `Credit` | 12 | 0 | 9 | Lift-tested | `Ola Irgens Kylling` (sole author, 21 files) — pure upstream; capitalised `Entities/` + `Verbs/` dirs (content-driven parser is dir-case-agnostic) | | `CustomerSupport` | 7 | 31 | 2 | Imported | | | `Data` | 1 | 1 | 0 | Imported | | | `DataProcessing` | 2 | 6 | 0 | Imported | | @@ -104,18 +138,18 @@ round-trip stress test. | `RPA` | 6 | 1 | 1 | Imported | | | `Religion` | 1 | 0 | 0 | Imported | | | `SaaS` | 10 | 12 | 0 | Imported | | -| `SalesDistribution` | 12 | 11 | 0 | Imported | | +| `SalesDistribution` | 12 | 11 | 0 | Lift-tested | `Marek Meyer` (sole author, 23 files) — pure upstream | | `Schedule` | 5 | 7 | 0 | Imported | | | `Security` | 2 | 0 | 0 | Imported | | -| `ServiceManagement` | 17 | 42 | 0 | Imported | MARS Machine `generates` Log/Timeseries lands here | +| `ServiceManagement` | 17 | 42 | 0 | Lift-tested | 8 distinct authors led by `Peter Larem` (42 files); pure upstream. MARS Machine `generates` Log/Timeseries lands here. | | `SharePoint` | 0 | 2 | 0 | Imported | Attributes-only | | `Software` | 5 | 0 | 0 | Imported | Distinct from `NTO/MARS/Software/` — this is a software-engineering vocabulary | | `Statistics` | 1 | 0 | 0 | Imported | | | `Survey` | 3 | 0 | 0 | Imported | | -| `Transport` | 5 | 14 | 8 | Imported | | +| `Transport` | 5 | 14 | 8 | Lift-tested | `chris.boos@almato.com` (sole author, 27 files) — pure upstream-arago | | `UserMeta` | 4 | 0 | 4 | Imported | | | `Version` | 0 | 3 | 0 | Imported | Used by MARS Machine for OS version | -| `WorkOrder` | 15 | 0 | 12 | Imported | Covered conceptually by WoA (`0x0003`) | +| **`WorkOrder`** | 27 | 0 | 0 | **Lift-tested** | **Our extension** (`dcterms:creator` = `bus-compiler` + `family-codec-smith` — internal agent authors, zero external). Authored for `woa-rs`. All 27 TTLs declared as `rdfs:Class`, including the 12 in `verbs/` (unusual `rdfs:Class`-as-verb convention). Round-trips cleanly. **Since we're upstream**, the verb files can be re-authored as `owl:ObjectProperty` for the AST predicate registry without external coordination. Previous catalogue row split 15 entities + 12 verbs by directory; content-driven count is 27 entities (what `ogar-from-schema` actually sees). | | **TOTALS** | **549** | **599** | **241** | — | + 42 other (Medical sql_mirror, etc.) | ## Adjacent imports (not NTO) From b598460822b0194ed50c7a61b184cb8dd02324de Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 22:13:17 +0000 Subject: [PATCH 2/4] docs(verb-as-class): the rdfs:Class-as-verb convention is an askama-shaped action template, not a quirk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WorkOrder's 12 verbs/*.ttl are declared as `a rdfs:Class`, not `owl:ObjectProperty`. The prior commit framed this as "an unusual convention we're free to revise" — wrong framing. The encoding is load-bearing. A `rdfs:Class` verb carries a typed slot list (`ogit:mandatory-attributes`), an inheritance chain (`rdfs:subClassOf`), and policy metadata (`ogit:requires-perm`, `ogit:emits-audit`). That makes each verb a compile-time-validated action template — the ontological counterpart to askama (Rust) and jinja (Python) HTML templating. Structural correspondence is exact: * TTL file = template (.html.j2 equivalent) * mandatory-attributes = struct field list (askama context shape) * per-call binding = struct instance * render = SPO triple emit + declared side effects (audit, ACL gate) * rdfs:subClassOf = template inheritance ({% extends %}) * lift-time slot validation = askama's compile-time {{ field }} check The existing ogar-render-askama crate is the natural landing — it already renders Class views (noun-shaped); the verb-as-class encoding is the parallel render path for Class actions (verb-shaped). Same engine, different output medium. Foundry-parity sharpens: Foundry's "action types" carry typed parameters + slot validation + declared side effects + inheritance — sold as a paid platform feature. Verb-as-class TTL + ogar-render-askama gives the same four from open-source schemas and Rust templates. Adds: * docs/VERB-AS-CLASS-TEMPLATE.md (FRAMING v0) — analogy table, worked example on WorkOrder/verbs/AccessesPortal.ttl, render flow, cross-references to the existing render-askama crate. * docs/OGIT-DOMAIN-LIFT-CATALOGUE.md (WorkOrder row): walks back the "we can re-author to owl:ObjectProperty" framing — the convention is deliberate and load-bearing for the action-render path. * .claude/board/EPIPHANIES.md: FINDING entry with the correction citing the prior commit, the analogy table, and the implications for the ogar-render-askama actions submodule. No code changes. Lift-tested round-trip count unchanged (16/16); verb-as-class TTLs already parse cleanly via ttl::parse_file as entities and round-trip via ttl_emit::emit_entity. --- .claude/board/EPIPHANIES.md | 50 +++++++++ docs/OGIT-DOMAIN-LIFT-CATALOGUE.md | 2 +- docs/VERB-AS-CLASS-TEMPLATE.md | 159 +++++++++++++++++++++++++++++ 3 files changed, 210 insertions(+), 1 deletion(-) create mode 100644 docs/VERB-AS-CLASS-TEMPLATE.md diff --git a/.claude/board/EPIPHANIES.md b/.claude/board/EPIPHANIES.md index a114036..be492ed 100644 --- a/.claude/board/EPIPHANIES.md +++ b/.claude/board/EPIPHANIES.md @@ -15,6 +15,56 @@ ## Entries (newest first) +## 2026-06-22 — Verb-as-class is an ontological askama template — compile-time-validated action declaration, not a quirk +**Status:** FINDING +**Scope:** WorkOrder convention × `ogar-render-askama` integration × Foundry action-type parity + +WorkOrder's 12 `verbs/*.ttl` are declared as `rdfs:Class`, not +`owl:ObjectProperty`. The earlier framing (commit `cce8420`) called +this "an unusual convention we're free to revise toward standard +`owl:ObjectProperty`" — **that framing was wrong** and is hereby +corrected. + +The verb-as-class encoding is **load-bearing**: it makes each verb a +typed template carrying its own slot list (`ogit:mandatory-attributes`), +inheritance chain (`rdfs:subClassOf`), and policy metadata +(`ogit:requires-perm`, `ogit:emits-audit`). That's not a flat predicate; +that's a **compile-time-validated action declaration** — the ontological +counterpart to askama (Rust) and jinja (Python) HTML templating. + +The structural correspondence is exact: + +- TTL file = template (`.html.j2` equivalent) +- `ogit:mandatory-attributes` = struct field list (askama context shape) +- Per-call binding = struct instance (askama render input) +- Render = SPO triple emit + declared side effects (audit, ACL gate) +- `rdfs:subClassOf` = template inheritance (`{% extends %}`) +- Lift-time slot validation = askama's compile-time `{{ field }}` check + +This is the integration point `ogar-render-askama` was always going +to need for actions. The crate currently renders `Class` *views* +(noun-shaped: HTML/JSON/OpenAPI); a parallel `actions/` submodule +renders `Class` *actions* (verb-shaped: SPO triple + side-effect spec). +Same engine, same compile-time-validated context model, different +output medium. + +**Foundry-parity sharpening:** Foundry's "action types" carry exactly +the four properties this encoding gives — typed parameters, slot +validation, declared side effects, inheritance. Foundry sells it as a +paid platform feature; verb-as-class TTL + `ogar-render-askama` gives +the same four from open-source schemas and Rust templates. + +Implications: +- **WorkOrder's convention stays.** Don't normalise to `owl:ObjectProperty`. +- **WorkOrder is the natural prototyping ground** (we're upstream per + `dcterms:creator` = `bus-compiler` + `family-codec-smith`) for new + verb-as-class predicates before pitching the pattern to OGIT upstream. +- **`ogar-render-askama::actions` is the next natural module** — + ~200 LOC mirroring the existing `views/` render path. + +Doc: `docs/VERB-AS-CLASS-TEMPLATE.md` (FRAMING v0) carries the full +analogy table + worked example + render flow. + ## 2026-06-22 — Author provenance via `dcterms:creator` discriminates "ours to revise" from "upstream-coordinated" **Status:** FINDING **Scope:** OGIT NTO governance × multi-domain lift × who-can-change-what diff --git a/docs/OGIT-DOMAIN-LIFT-CATALOGUE.md b/docs/OGIT-DOMAIN-LIFT-CATALOGUE.md index 09440a0..6066bab 100644 --- a/docs/OGIT-DOMAIN-LIFT-CATALOGUE.md +++ b/docs/OGIT-DOMAIN-LIFT-CATALOGUE.md @@ -149,7 +149,7 @@ arago/almato coordination." | `Transport` | 5 | 14 | 8 | Lift-tested | `chris.boos@almato.com` (sole author, 27 files) — pure upstream-arago | | `UserMeta` | 4 | 0 | 4 | Imported | | | `Version` | 0 | 3 | 0 | Imported | Used by MARS Machine for OS version | -| **`WorkOrder`** | 27 | 0 | 0 | **Lift-tested** | **Our extension** (`dcterms:creator` = `bus-compiler` + `family-codec-smith` — internal agent authors, zero external). Authored for `woa-rs`. All 27 TTLs declared as `rdfs:Class`, including the 12 in `verbs/` (unusual `rdfs:Class`-as-verb convention). Round-trips cleanly. **Since we're upstream**, the verb files can be re-authored as `owl:ObjectProperty` for the AST predicate registry without external coordination. Previous catalogue row split 15 entities + 12 verbs by directory; content-driven count is 27 entities (what `ogar-from-schema` actually sees). | +| **`WorkOrder`** | 27 | 0 | 0 | **Lift-tested** | **Our extension** (`dcterms:creator` = `bus-compiler` + `family-codec-smith` — internal agent authors, zero external). Authored for `woa-rs`. All 27 TTLs declared as `rdfs:Class`, including the 12 in `verbs/`. **The `rdfs:Class`-as-verb convention is deliberate, not a quirk** — it makes each verb a typed template (slots, inheritance, policy metadata) that `ogar-render-askama` can compile-time-validate against a binding, the same way askama validates HTML templates against a Rust struct. See `docs/VERB-AS-CLASS-TEMPLATE.md`. Previous catalogue row split 15 entities + 12 verbs by directory; the content-driven count is 27 first-class typed declarations (entities + verb-as-class templates), which is what `ogar-from-schema` sees and what the action-render path consumes. | | **TOTALS** | **549** | **599** | **241** | — | + 42 other (Medical sql_mirror, etc.) | ## Adjacent imports (not NTO) diff --git a/docs/VERB-AS-CLASS-TEMPLATE.md b/docs/VERB-AS-CLASS-TEMPLATE.md new file mode 100644 index 0000000..7da71ef --- /dev/null +++ b/docs/VERB-AS-CLASS-TEMPLATE.md @@ -0,0 +1,159 @@ +# Verb-as-class — the ontological askama/jinja + +> **Insight (operator, 2026-06-22).** When a verb is encoded as +> `rdfs:Class` instead of `owl:ObjectProperty`, the TTL file becomes +> a **compile-time-validated action template** — the ontological +> counterpart to askama (Rust) and jinja (Python) HTML templating. +> WorkOrder uses this convention (12 verbs declared as classes); the +> existing `ogar-render-askama` crate is the natural integration +> point. +> +> Status: **FRAMING v0** (2026-06-22). Companion to +> `docs/OGIT-DOMAIN-LIFT-CATALOGUE.md` (WorkOrder row) and +> `docs/FOUNDRY-ODOO-MARS-LENS.md` (the Foundry-parity angle). + +The convention in one sentence: **a `rdfs:Class` verb declares a typed +slot list; a render takes a context binding and produces a materialised +SPO triple + declared side effects; the engine validates slot↔binding +at the same point askama validates `{{ name }}` against the struct +field.** + +## The two encodings compared + +| | `owl:ObjectProperty` verb | `rdfs:Class` verb | +|---|---|---| +| Carries | name + description | name + description + **slot list** + **inheritance** + **policy attributes** | +| Subject/object types | implied (by domain/range, often missing in OGIT) | explicit (`ogit:mandatory-attributes` enumerates the slots) | +| Inheritance | not native | `rdfs:subClassOf` gives template inheritance | +| Policy metadata | not native | any attribute the consumer wants (`ogit:requires-perm`, `ogit:emits-audit`) | +| Compile-time slot validation | no — verbs are flat names | yes — `ogar-from-schema` lifts the slot list; the renderer checks bindings | +| Round-trip with `ogar-from-schema` | yes (via `sgo::parse_verb`) | yes (via `ttl::parse_file` as Entity) | +| **Renders as** | a label on an edge | a typed action with side effects | + +Both encodings round-trip cleanly today. The choice is about **what the +verb is *for*** — a flat predicate (use `owl:ObjectProperty`, like +SGO's 176) or a typed action template (use `rdfs:Class`, like WorkOrder's 12). + +## Term-for-term with askama / jinja + +``` +askama / jinja │ ontology (verb-as-class) +───────────────────────────── │ ───────────────────────────────────────── +template.html.j2 │ vocab/imports/ogit/NTO//verbs/.ttl + │ a rdfs:Class +struct Context { name: String │ ogit:mandatory-attributes ( + │ ogit:subject + │ ogit:object + │ … + │ ) +context binding │ per-call value map +{% extends "base.html.j2" %} │ rdfs:subClassOf ogit:AuditableAction +filters / macros │ ogit:requires-perm, ogit:emits-audit +compile-time slot check │ ogar-from-schema validates the slot list + │ against the binding at lift time +render() → HTML string │ render() → (SPO triple, audit record, + │ ACL decision, side effects) +``` + +The structural correspondence is exact. Both are +**compile-time-validated declarative templates**; both separate +"template" (TTL file / `.html.j2`) from "context" (binding / struct); +both surface invalid bindings before render. The output medium +differs (HTML string vs. graph delta) but the engine shape is the same. + +## A worked example — `WorkOrder/verbs/AccessesPortal.ttl` + +```turtle +ogit.WorkOrder:AccessesPortal + a rdfs:Class; # VERB-AS-CLASS + rdfs:subClassOf ogit:AuditableAction; # inherits audit slot + rdfs:label "AccessesPortal"; + ogit:mandatory-attributes ( # SLOTS — like askama struct fields + ogit:subject # who + ogit:object # what portal + ogit:timestamp # when + ); + ogit:requires-perm "portal_login"; # template metadata + ogit:emits-audit "true"; # render-time side effect +. +``` + +When `User#42 accesses Portal#1` at time `T`: + +1. **Lookup** — the renderer resolves `AccessesPortal` to its lifted + `Class` (the template). +2. **Bind** — the binding `{ subject: User#42, object: Portal#1, + timestamp: T }` is type-checked against the slot list. Missing + `timestamp` ⇒ render-time error (same as askama: missing struct field + ⇒ compile error). +3. **Render** — emit: + - SPO triple `(User#42, AccessesPortal#, Portal#1)` with + `timestamp = T` + - Audit record (because `emits-audit = true`) + - ACL gate (must satisfy `requires-perm = "portal_login"`) + +The output is a **graph delta + side-effect spec**, not a string. + +## Why this is the right integration point for `ogar-render-askama` + +The crate already does askama-template rendering for `Class` **views** +(per-app skin per `docs/OGAR-CONSUMER-BEST-PRACTICES.md`). Verb-as-class +is the parallel render path for `Class` **actions**: + +``` +ogar-render-askama/ +├── views/ — Class views (HTML, JSON, OpenAPI) ← EXISTING +└── actions/ — Class actions (verb-as-class render) ← NEW (this framing) +``` + +Same `Class` IR, same askama engine, same compile-time-validated +template/binding pattern. Dispatch is on what shape the `Class` +declares: noun-shaped (entity fields) → view render; verb-shaped +(`mandatory-attributes` = subject/object/timestamp/…) → action render. + +## The Foundry-parity sharpening + +Foundry's "action types" carry exactly the four properties listed in +the comparison table: + +1. Typed parameters (slots) +2. Compile-time validation of bindings +3. Declared side effects (audit, ACL, downstream effects) +4. Inheritance / composition + +Foundry sells those as a paid platform feature. Verb-as-class TTL + +`ogar-render-askama` gives the same four properties from +**open-source schemas and Rust templates** — no vendor, no lock. + +## What this changes for the next session + +Three small implications: + +1. **Don't normalise WorkOrder's convention** to `owl:ObjectProperty`. + The earlier framing (in the commit message of `cce8420`) was wrong; + re-encoding would strip the template surface. + +2. **The `actions/` submodule in `ogar-render-askama` is the natural + landing** for the verb-as-class renderer. Not implemented today; + ~200 LOC mirroring the existing `views/` render path. + +3. **The verb-as-class convention is a candidate for prototyping + in WorkOrder first** (since we're upstream — `dcterms:creator` = + `bus-compiler` / `family-codec-smith`), then pitching to OGIT + upstream once `ogar-render-askama`'s actions path has proven the + pattern. + +## Cross-references + +- `crates/ogar-render-askama/` — the existing askama renderer (views + today; actions tomorrow) +- `crates/ogar-from-schema/` — the lift that turns TTL into the IR + the renderer consumes +- `docs/OGIT-DOMAIN-LIFT-CATALOGUE.md` — WorkOrder row with the + verb-as-class convention note +- `docs/FOUNDRY-ODOO-MARS-LENS.md` — the Foundry-parity argument + that verb-as-class sharpens +- `docs/OGAR-CONSUMER-BEST-PRACTICES.md` — the per-app `ClassView` + pattern that views-side rendering already follows +- `vocab/imports/ogit/NTO/WorkOrder/verbs/*.ttl` — the 12 verb-as-class + templates this framing describes From 7d6804267d4b86db17c971975d9f70faccf98b04 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 22:21:51 +0000 Subject: [PATCH 3/4] =?UTF-8?q?docs(odoo):=20digest=20=E2=86=92=20OGIT=20T?= =?UTF-8?q?TL=20templates=20=E2=86=92=20relive=20agnostically=20=E2=80=94?= =?UTF-8?q?=20the=20Foundry-parity=20collapse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The operator's framing — "digest Odoo and store it in TTL Jinja Templates in OGIT and relive it agnostically for any verb/entity as a class" — crystallizes the four pieces shipped across this PR (TTL mirror, schema lift, verb-as-class template, dcterms:creator provenance) into one coherent pipeline: Odoo source → ogar-from-python (AST schema filter) → Class IR → ttl_emit → OGIT TTL templates at vocab/imports/ogit/NTO// with dcterms:creator = bus-compiler → ogar-render-askama → any consumer instantiates any entity-or-verb-as-class with a fresh binding; Python runtime touched only at digest time. Why store digests in OGIT NTO (not a parallel vocab/imports/odoo/): the dcterms:creator author-scan from this same session gives clean provenance — internal-agent authors (bus-compiler, family-codec-smith) signal "ours to revise"; external-human authors signal "upstream-arago, coordinate". The precedent exists today: Accounting/ already has 11 Claude-digested files alongside 23 Viktor Voss originals. The Foundry-parity collapse (the punchline) — Foundry's four-layer platform pitch maps to four pieces in this repo: Foundry ingest → ogar-from-python digest (~1500 LOC, queued) Foundry storage → vocab/imports/ogit/NTO/ TTL (exists) Foundry render → ogar-render-askama::{views, actions} (~200 LOC for actions) Foundry IAM+audit → verb-as-class requires-perm slot + Lance-version-as-audit (exists) Foundry change mgmt → diff -r of digest re-runs (exists) Total marginal code: <2000 LOC for what Foundry charges $$$ for. Adds: * docs/ODOO-DIGEST-TO-OGIT.md (FRAMING v0, 8 sections): pipeline diagram; why-store-in-OGIT-NTO; the four shapes the digester produces (entity-as-class, datatype attribute, association, verb-as-class); v0 mapping table from the 6 already-minted commerce concepts + 9 queued for codebook mint; drift-detector recipe; blockers; Foundry-parity collapse table; cross-references. * .claude/board/EPIPHANIES.md FRAMING entry: the pipeline, the storage-location rationale, the parity-collapse table, and the two concrete next-step deliverables (ogar-from-python + ogar-render-askama::actions) that ship independently in follow-up PRs. No code changes. The architecture has been latent the whole time; this commit makes it visible as one shape. --- .claude/board/EPIPHANIES.md | 63 +++++++++++ docs/ODOO-DIGEST-TO-OGIT.md | 206 ++++++++++++++++++++++++++++++++++++ 2 files changed, 269 insertions(+) create mode 100644 docs/ODOO-DIGEST-TO-OGIT.md diff --git a/.claude/board/EPIPHANIES.md b/.claude/board/EPIPHANIES.md index be492ed..9d40721 100644 --- a/.claude/board/EPIPHANIES.md +++ b/.claude/board/EPIPHANIES.md @@ -15,6 +15,69 @@ ## Entries (newest first) +## 2026-06-22 — OGIT is the canonical template store; Odoo (and any source-AST producer) digests INTO it; consumers relive agnostically via askama +**Status:** FRAMING +**Scope:** Foundry-parity collapse × cross-consumer architecture × digest-once-relive-N + +The operator's framing — *"basically digest Odoo and store it in TTL +'Jinja' Templates in OGIT and relive it agnostically for any +'verb/entity as a class'"* — crystallizes the four pieces shipped +across this PR (TTL mirror, schema lift, verb-as-class template, +author-provenance discriminator) into one coherent flow: + +``` +Odoo source → ogar-from-python → Class IR → ttl_emit → OGIT TTL templates + (stored at + vocab/imports/ogit/NTO//, + dcterms:creator = bus-compiler) + │ + ▼ + ogar-render-askama + │ + ▼ + any consumer (woa-rs, smb-office-rs, medcare-rs, q2, future renderers) + re-instantiates any entity/verb-as-class with a fresh binding; + never touches Odoo Python +``` + +The Python runtime is **only** touched at digest time. After that, +every consumer talks to TTL templates plus the askama engine. + +**Why store in OGIT NTO (not a parallel `vocab/imports/odoo/`):** the +`dcterms:creator` author-scan finding from this same session makes +provenance unambiguous without a separate namespace. The precedent +exists today — `Accounting/` already has 11 Claude-digested files +sitting alongside 23 Viktor Voss originals. + +**Foundry-parity collapse** (the punchline). Foundry's four-layer +platform pitch (ingest / storage / render / IAM+audit) maps to four +free open-source pieces already in this repo: + +| Foundry layer | Our equivalent | New code needed | +|---|---|---| +| Ingest | `ogar-from-python` digest | ~1500 LOC (queued) | +| Storage | `vocab/imports/ogit/NTO//` TTL with `dcterms:creator` | zero (exists) | +| Render | `ogar-render-askama::{views, actions}` | ~200 LOC for actions submodule | +| IAM + audit | verb-as-class `requires-perm` slot + Lance-version-as-audit | zero (exists) | +| Ontology change mgmt | `diff -r` of digest re-runs | zero | + +Total marginal code: <2000 LOC for what Foundry charges $$$ for. The +architecture was latent the whole time; the digest→relive framing is +what makes it visible as a single shape. + +Concrete next steps (independent, can ship in parallel PRs): + +1. `ogar-from-python` digester — structural-arm filter (`_name`, + `_inherit`, `fields.*`, selections); behavioural-arm signatures + (decorator names + action method signatures); drops method bodies +2. `ogar-render-askama::actions` — verb-as-class render path, mirroring + the existing `views/` submodule + +Doc: `docs/ODOO-DIGEST-TO-OGIT.md` (FRAMING v0) carries the full +pipeline, the v0 mapping table (6 minted concepts + ~9 queued for +codebook mint), the drift detector recipe, and the Foundry-parity +collapse table. + ## 2026-06-22 — Verb-as-class is an ontological askama template — compile-time-validated action declaration, not a quirk **Status:** FINDING **Scope:** WorkOrder convention × `ogar-render-askama` integration × Foundry action-type parity diff --git a/docs/ODOO-DIGEST-TO-OGIT.md b/docs/ODOO-DIGEST-TO-OGIT.md new file mode 100644 index 0000000..a76ee20 --- /dev/null +++ b/docs/ODOO-DIGEST-TO-OGIT.md @@ -0,0 +1,206 @@ +# Odoo digest → OGIT TTL templates → agnostic relive + +> **For colleagues building anything that needs Odoo as a typed ontology +> outside the Odoo Python runtime.** This is the architectural shape: +> digest Odoo source once into OGIT-shaped TTL templates (stored +> in-tree at `vocab/imports/ogit/NTO//`), then relive any model +> or workflow action agnostically via `ogar-render-askama`. +> +> Status: **FRAMING v0** (2026-06-22). Companion to +> `docs/ODOO-TRANSCODING.md` (the producer spec) and +> `docs/VERB-AS-CLASS-TEMPLATE.md` (the askama-template framing this +> reuses). + +The shape in one sentence: **`ogar-from-python` digests Odoo source +once into the OGAR IR; `ttl_emit` writes the IR back as OGIT-shaped +TTL templates stored in the existing OGIT NTO tree; consumers +re-instantiate any model or workflow action by rendering against the +TTL via `ogar-render-askama`, never touching Odoo Python.** + +--- + +## §1. The pipeline + +``` + Odoo Python source + (addons//models/*.py) + │ + │ ogar-from-python (AST schema filter) + │ — keeps structural arm: _name, _inherit, fields.*, selections + │ — keeps behavioural-arm SIGNATURES (decorators, action def names) + │ — drops bodies (computed methods, action implementations) + ▼ + OGAR Class IR (in memory) + │ + │ ttl_emit::emit_entity (semantic bijection) + │ — entity-as-class for models + │ — verb-as-class for workflow action signatures + ▼ + OGIT-shaped TTL templates + (vocab/imports/ogit/NTO//.ttl) + — dcterms:creator = bus-compiler (digester provenance) + — alongside upstream arago TTL (Viktor Voss et al) + │ + │ ogar-render-askama (entity render → views; verb render → actions) + ▼ + Materialized output (any consumer, any medium) + — HTML view (per-app skin) + — JSON / OpenAPI surface + — SurrealQL DDL / Postgres CREATE TABLE + — SPO triple emit + ACL gate + audit record (action render) +``` + +The Python runtime is **only** touched at digest time. Consumers +(`woa-rs`, `smb-office-rs`, `medcare-rs`, `q2`, any future renderer) +never depend on Odoo Python, only on TTL + the askama renderer. + +## §2. Why store digests in OGIT NTO (not a parallel `vocab/imports/odoo/`) + +The `dcterms:creator` author-scan (`OGIT-DOMAIN-LIFT-CATALOGUE.md +§ Verifying domain authorship`) gives clean provenance without +needing a separate storage namespace: + +| `dcterms:creator` value | Meaning | Who can change | +|---|---|---| +| `Viktor Voss`, `chris.boos@almato.com`, `fotto@arago.de`, … | Upstream arago/almato | Re-vendor requires arago PR; OGAR consumes via the SHA pin | +| `bus-compiler`, `family-codec-smith`, `Claude (...)`, … | Internal agent digest | Re-run the digester; no external coordination | + +The precedent exists today: `vocab/imports/ogit/NTO/Accounting/` +already has 11 files from a prior `Claude (AdaWorldAPI/lance-graph +3-hop optim)` digest sitting alongside Viktor Voss's 23 originals. The +two co-exist, the author-scan distinguishes them, and lifting both +into OGAR's `Class` IR is symmetric. + +So **the digest lives where the concept belongs** — `account.move` → +`Accounting/`, `sale.order` → `SalesDistribution/`, `stock.picking` → +`Transport/` — and the author scan is the discriminator. + +## §3. The four shapes the digester produces + +| Shape | Source pattern | TTL form | Render path | +|---|---|---|---| +| **Entity-as-class** | `class Foo(models.Model): _name = '...'; ` | `rdfs:Class` + `mandatory-attributes` enumerating field names | view render (`ogar-render-askama::views`) | +| **Datatype attribute** | `field_name = fields.Char/Integer/Selection(...)` (with selection) | `owl:DatatypeProperty` + `ogit:validation-type "fixed"` + `validation-parameter "a,b,c"` | binding validation | +| **Association** | `partner_id = fields.Many2one('res.partner', ...)` | `owl:ObjectProperty` (lifted as SGO verb) OR an entry in the parent class's `ogit:allowed (...)` block | edge in lifted SPO graph | +| **Verb-as-class** | `def action_confirm(self)` workflow method | `rdfs:Class` + slot list (subject = SaleOrder, object = confirmation event) + policy attrs (`requires-perm`, `emits-audit`) | action render (`ogar-render-askama::actions`, queued) | + +The last shape is what makes "relive agnostically" possible for +workflow actions: the Python method body stays in Odoo (behavioural +arm), but the **action contract** (what it requires, what it emits, +what it depends on) becomes a typed template any consumer can render +against. + +## §4. The mapping table — v0 from the existing codebook + +The six commerce/ERP concepts already minted in `ogar-vocab::class_ids` +give the digester its first six targets. Concepts beyond these need +the 5+3 codebook pass (per `docs/APP-CLASS-CODEBOOK-LAYOUT.md §4`) +before mint; the digester can lift them in the meantime with +`Class.name` carrying the Odoo model name and no concept_id assigned. + +| Odoo model | Source addon path | OGIT NTO target | OGAR concept | Shape | Concept-mint status | +|---|---|---|---|---|---| +| `res.partner` | `addons/base/models/res_partner.py` | `Accounting/` | `BILLING_PARTY` `0x0204` | entity-as-class | minted | +| `account.move` | `addons/account/models/account_move.py` | `Accounting/` | `COMMERCIAL_DOCUMENT` `0x0202` | entity-as-class | minted | +| `account.move.line` | `addons/account/models/account_move_line.py` | `Accounting/` | `COMMERCIAL_LINE_ITEM` `0x0201` | entity-as-class | minted | +| `account.tax` | `addons/account/models/account_tax.py` | `Accounting/` | `TAX_POLICY` `0x0203` | entity-as-class | minted | +| `account.payment` | `addons/account/models/account_payment.py` | `Accounting/` | `PAYMENT_RECORD` `0x0205` | entity-as-class | minted | +| `res.currency` | `addons/base/models/res_currency.py` | `Accounting/` | `CURRENCY_POLICY` `0x0206` | entity-as-class | minted | +| `sale.order` | `addons/sale/models/sale_order.py` | `SalesDistribution/` | TBD `0x02??` | entity-as-class | needs mint | +| `sale.order.line` | `addons/sale/models/sale_order_line.py` | `SalesDistribution/` | TBD | entity-as-class | needs mint | +| `stock.picking` | `addons/stock/models/stock_picking.py` | `Transport/` | TBD | entity-as-class | needs mint | +| `hr.employee` | `addons/hr/models/hr_employee.py` | `HR/` | TBD | entity-as-class | needs mint | +| `crm.lead` | `addons/crm/models/crm_lead.py` | (new NTO?) | TBD | entity-as-class | needs mint + domain decision | +| `product.product` | `addons/product/models/product_product.py` | (new NTO?) | TBD | entity-as-class | needs mint + domain decision | +| `account.move::action_post` | workflow method on `account.move` | `Accounting/verbs/Post.ttl` | TBD | **verb-as-class** | needs mint | +| `sale.order::action_confirm` | workflow method on `sale.order` | `SalesDistribution/verbs/Confirm.ttl` | TBD | **verb-as-class** | needs mint | +| `stock.picking::action_assign` | workflow method on `stock.picking` | `Transport/verbs/Assign.ttl` | TBD | **verb-as-class** | needs mint | + +The table grows additively. Each new Odoo addon module digested by +the v0 producer adds rows; concept-mint passes work in parallel. + +## §5. The drift detector — Foundry's "ontology change management" for free + +``` +Day 1 — digest Odoo at SHA-A + ogar-from-python addons/account → vocab/imports/ogit/NTO/Accounting/*.ttl + git commit (the TTL set is the frozen contract) + +Day N — Odoo upstream releases SHA-B + ogar-from-python addons/account → /tmp/odoo-shaB-digest/ + diff -r vocab/imports/ogit/NTO/Accounting/ /tmp/odoo-shaB-digest/ + + Any output line is a structural change Odoo just made: + - added field → diff shows a new ogit:optional-attributes entry + - renamed column → diff shows the rename + - extended selection → diff shows the new validation-parameter values + - changed _inherit → diff shows the rdfs:subClassOf rewire + - dropped a model → diff shows file deletion +``` + +Same diff fires in CI on the same PR if a contributor edits an Odoo +model without re-running the digest. **The TTL templates are the +contract; the digest re-run is the audit; the diff is the gate.** +Foundry sells this as "ontology change management" for a recurring +license fee. + +## §6. What blocks doing it today + +| Piece | Status | +|---|---| +| Storage location (`vocab/imports/ogit/NTO//`) | exists; 72 domains imported, MARS oracle proven | +| TTL emitter for the structural arm | exists (`ttl_emit::emit_entity`); semantic bijection proven on 29 MARS + 176 SGO TTLs | +| Verb-as-class template surface | exists (WorkOrder convention; `docs/VERB-AS-CLASS-TEMPLATE.md`) | +| Author-provenance discriminator | exists (`dcterms:creator` scan in `OGIT-DOMAIN-LIFT-CATALOGUE.md`) | +| `ogar-render-askama::actions` (verb-as-class render path) | **does not exist** — ~200 LOC mirroring the existing `views/` path | +| `ogar-from-python` (the digester) | **does not exist** — needs `libcst` or `rustpython-parser` to walk Python AST; ~1500 LOC for the structural-arm filter | +| Concept mints for non-Accounting Odoo models | needs the 5+3 codebook pass per `APP-CLASS-CODEBOOK-LAYOUT.md` | + +`ogar-from-python` and `ogar-render-askama::actions` are independent +and can ship in parallel PRs. Concept mints are the slow path +(codebook discipline) and don't block the digest — a digested model +without a minted concept_id just gets `Class.name = "sale.order"` and +gets the id assigned later. + +## §7. The Foundry-parity collapse + +This section is the punchline. Foundry's platform pitch decomposes +into four layers; each layer maps to a free, open-source piece in +this architecture: + +| Foundry layer | Vendor cost | Our equivalent | Marginal cost | +|---|---|---|---| +| Ingest (vendor pipelines) | $ | `ogar-from-python` digest run (one-shot per Odoo upgrade) | engineer-hours per digester ~1500 LOC | +| Storage (vendor platform) | $$ | `vocab/imports/ogit/NTO//` TTL templates with `dcterms:creator` provenance | zero | +| Render (vendor UI) | $$ | `ogar-render-askama::{views, actions}` | engineer-hours per render path ~200 LOC each | +| Access control / audit (vendor IAM) | $$$ | verb-as-class `requires-perm` slot + `emits-audit` + Lance-version-as-audit (ADR-013) | zero (the substrate already does it) | +| Ontology change management (vendor feature) | $$$ | `diff -r` of digest output (§5) | zero | + +The substrate eats every layer the platform sells, **using artifacts +that already exist in this repo**, in less than 2000 lines of new +code. The architecture has been latent the whole time — your +"digest → relive agnostically" framing is what makes it visible as +one shape. + +## §8. Cross-references + +- `docs/ODOO-TRANSCODING.md` — the existing producer spec (sections 1-18: + module discovery, field type mapping, attribute kwargs, association + kwargs, enum sources, class-level metadata, state machines, `_inherit` + resolution, decorator mapping, CRUD overrides, registered prefixes, + conformance corpus). The "how" of the digest. +- `docs/VERB-AS-CLASS-TEMPLATE.md` — the askama-template framing the + digest reuses for workflow actions. +- `docs/HIRO-IN-CLASSES.md` — the bardioc-efficiency story (the same + digest pattern applied to MARS; this doc generalises it to Odoo). +- `docs/FOUNDRY-ODOO-MARS-LENS.md` — the three-postures cross-reading + (MARS frozen schema, Odoo extensible source, Foundry vendor platform). +- `docs/OGIT-DOMAIN-LIFT-CATALOGUE.md` — coverage register; new Odoo + digests advance the rows from Imported → Lift-tested → Cross-walked. +- `docs/APP-CLASS-CODEBOOK-LAYOUT.md` — the 5+3 mint protocol for + new concept_ids (the §6 slow path). +- `crates/ogar-from-schema/` — the schema-arm producer (TTL today; + XSD queued) that the Odoo digester pairs with on the structural + boundary. +- `crates/ogar-render-askama/` — the askama renderer (views today; + actions queued). From 8945b7fa9788821694d88c2c704b400b22a15088 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 22:36:16 +0000 Subject: [PATCH 4/4] =?UTF-8?q?feat(xsd):=20transcode=20extract=5Fclasses.?= =?UTF-8?q?py=20to=20Rust=20=E2=80=94=20byte-faithful,=20closes=20XSD?= =?UTF-8?q?=E2=86=94TTL=20bijection,=20drops=20the=20Python=20oracle=20dep?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MARS XSD classification extractor (arago/MARS-Schema/tools/extract_classes.py, ~360 lines: ~140 extraction logic + ~150 table formatting) is now a faithful Rust transcode at crates/ogar-from-schema/src/xsd.rs, behind an optional `xsd` feature (pulls roxmltree, pure-Rust read-only XML DOM; the default TTL path stays zero-parser-deps). Not huge — the transcode is ~350 LOC Rust including tests. And it doubles as the seed of the broader XSD → Class front-end: the same walk that extracts classifications is the structural-arm lift for any XSD schema. What it lands: * BYTE-FOR-BYTE transcode proof. xsd::to_asciidoc() reproduces the Python `-F asciidoc` output exactly — 628 lines, including verbatim XSD-documentation whitespace and the printAsciiDocFooter trailing newline. Test xsd::tests::asciidoc_matches_python_oracle diffs against the cached _oracle/classifications.adoc. * XSD↔TTL BIJECTION CLOSED (was "queued" in MARS-TRANSCODING.md §2). xsd::tests::xsd_classes_match_ttl_enum asserts full bidirectional set-equality between the XSD-extracted Application value set and the TTL validation-parameter enum — not just one-directional membership. Two independent encodings of one taxonomy, provably equal both ways. * PYTHON DEPENDENCY REMOVED from the calibration path. `cargo test --features xsd` is the whole oracle now; no python3 interpreter needed. extract_classes.py stays vendored in _oracle/ as the provenance witness (what the transcode was proven against), not a runtime dep. Transcode discipline (faithful to the Python semantics): * getAttribute("xml:lang") returns "" for absent (not None); lang filter is "absent OR en". roxmltree resolves xml: to the xml namespace, matched on attribute.name() == "lang". * getXMLText concatenates DIRECT text-node children only (not recursive); the documentation's internal whitespace is load-bearing for the byte-match. * :revdate: is datetime.now() in Python (non-deterministic); the Rust to_asciidoc(c, revdate) takes it as a parameter so output is reproducible and testable. * The two-level extension chain (master complexType carries NodeType, intermediate complexType carries Class, leaf element carries SubClass) + the post-process phase that stitches base→element is reproduced exactly, including the master_types gate. Tests: 20/20 with --features xsd (16 default + 4 new xsd); 16/16 on default (xsd code fully feature-gated). Clippy-clean (--no-deps), fmt-clean. Docs: * docs/MARS-TRANSCODING.md §2 — bijection marked closed; Python-dep removal noted; the Rust extractor added to the oracle-direction table. * .claude/board/EPIPHANIES.md — FINDING with the transcode-discipline notes for the next source→Rust port. --- .claude/board/EPIPHANIES.md | 52 ++++ crates/ogar-from-schema/Cargo.toml | 17 +- crates/ogar-from-schema/src/lib.rs | 2 + crates/ogar-from-schema/src/xsd.rs | 471 +++++++++++++++++++++++++++++ docs/MARS-TRANSCODING.md | 21 +- 5 files changed, 552 insertions(+), 11 deletions(-) create mode 100644 crates/ogar-from-schema/src/xsd.rs diff --git a/.claude/board/EPIPHANIES.md b/.claude/board/EPIPHANIES.md index 9d40721..c215a26 100644 --- a/.claude/board/EPIPHANIES.md +++ b/.claude/board/EPIPHANIES.md @@ -15,6 +15,58 @@ ## Entries (newest first) +## 2026-06-22 — extract_classes.py transcoded to Rust byte-faithfully; XSD↔TTL bijection closed; Python dependency removed from the oracle +**Status:** FINDING +**Scope:** XSD front-end × calibration self-containment × the queued bijection + +The MARS XSD classification extractor (`arago/MARS-Schema/tools/extract_classes.py`, +~360 lines, ~140 logic + ~150 table formatting) is now a faithful Rust +transcode at `crates/ogar-from-schema/src/xsd.rs`, behind the optional +`xsd` feature (pulls `roxmltree`, a pure-Rust read-only XML DOM; the +default TTL path stays zero-parser-deps). + +Three things this lands: + +1. **Byte-for-byte transcode proof.** `xsd::to_asciidoc()` reproduces + the Python `-F asciidoc` output exactly — 628 lines, including the + verbatim XSD-documentation whitespace and the `printAsciiDocFooter` + trailing newline. Test: `xsd::tests::asciidoc_matches_python_oracle` + diffs against the cached `_oracle/classifications.adoc`. + +2. **The XSD↔TTL bijection is closed (was "queued" in + `MARS-TRANSCODING.md §2`).** `xsd::tests::xsd_classes_match_ttl_enum` + asserts FULL bidirectional set-equality between the XSD-extracted + Application value set and the TTL `validation-parameter` enum — not + just one-directional membership. The XSD and the TTL are two + independent encodings of one taxonomy and they now provably agree + in both directions. + +3. **The Python dependency is removed from the calibration path.** + `cargo test --features xsd` is the whole oracle now; no `python3` + interpreter needed. `extract_classes.py` stays vendored in + `_oracle/` as the provenance witness (what the transcode was proven + against), not a runtime dep. + +Transcode discipline notes (for the next source→Rust port): +- The Python `getAttribute("xml:lang")` returns `""` for absent (not + `None`); the lang-filter is "absent OR en". roxmltree resolves `xml:` + to the xml namespace — match on `attribute.name() == "lang"`. +- `getXMLText` concatenates DIRECT text-node children only (not + recursive); the documentation's internal whitespace is load-bearing + for the byte-match. +- The `:revdate:` is `datetime.now()` in Python (non-deterministic); + the Rust `to_asciidoc(c, revdate)` takes it as a parameter so the + output is reproducible and testable. + +Answer to "is it huge": no — ~360 lines, half output formatting; the +transcode is ~350 LOC Rust including tests. And it doubles as the seed +of the broader XSD→`Class` front-end (the same walk that extracts +classifications is the structural-arm lift for any XSD). + +Evidence: `crates/ogar-from-schema/src/xsd.rs` (20/20 tests pass with +`--features xsd`; 16/16 on default). `docs/MARS-TRANSCODING.md §2` +updated to mark the bijection closed. + ## 2026-06-22 — OGIT is the canonical template store; Odoo (and any source-AST producer) digests INTO it; consumers relive agnostically via askama **Status:** FRAMING **Scope:** Foundry-parity collapse × cross-consumer architecture × digest-once-relive-N diff --git a/crates/ogar-from-schema/Cargo.toml b/crates/ogar-from-schema/Cargo.toml index a717832..efafbce 100644 --- a/crates/ogar-from-schema/Cargo.toml +++ b/crates/ogar-from-schema/Cargo.toml @@ -11,14 +11,21 @@ description = "Schema-as-input producer family for OGAR IR. Lifts the STRUCTURAL [features] default = [] serde = ["dep:serde", "ogar-vocab/serde"] +# The `xsd` feature pulls in a read-only XML parser for the XSD +# front-end (the faithful Rust transcode of arago/MARS-Schema's +# extract_classes.py). Kept optional so the default TTL path stays +# zero-parser-deps. +xsd = ["dep:roxmltree"] [dependencies] ogar-vocab = { path = "../ogar-vocab" } serde = { workspace = true, optional = true } +roxmltree = { version = "0.20", optional = true } -# No external TTL/XSD parser pulled in yet: the v0 reader uses a -# narrow line-oriented walker that handles the shapes OGIT's TTL -# actually emits (a tiny, machine-stable subset of full Turtle). -# When the surface grows (Wikidata-shaped TTL, full RDF/XML, OWL -# imports), swap in `oxttl` / `oxrdf` here without touching the +# The default TTL reader uses a narrow line-oriented walker that +# handles the shapes OGIT's TTL actually emits (a tiny, machine-stable +# subset of full Turtle) — no external parser. The `xsd` feature adds +# roxmltree (pure-Rust, read-only DOM) ONLY for the XSD front-end. +# When the TTL surface grows (Wikidata-shaped TTL, full RDF/XML, OWL +# imports), swap in `oxttl` / `oxrdf` without touching the # Class/Attribute target shape this crate produces. diff --git a/crates/ogar-from-schema/src/lib.rs b/crates/ogar-from-schema/src/lib.rs index 67c4e9a..c5a1a8c 100644 --- a/crates/ogar-from-schema/src/lib.rs +++ b/crates/ogar-from-schema/src/lib.rs @@ -60,6 +60,8 @@ use ogar_vocab::{Attribute, Class, EnumDecl, EnumSource, Language}; pub mod sgo; pub mod ttl; pub mod ttl_emit; +#[cfg(feature = "xsd")] +pub mod xsd; /// What a single TTL file describes — exactly one of: an entity (`Class`), /// a datatype attribute (`Attribute`), or a verb (`Association` shape). diff --git a/crates/ogar-from-schema/src/xsd.rs b/crates/ogar-from-schema/src/xsd.rs new file mode 100644 index 0000000..53bbb1e --- /dev/null +++ b/crates/ogar-from-schema/src/xsd.rs @@ -0,0 +1,471 @@ +//! XSD front-end — a faithful Rust transcode of `arago/MARS-Schema`'s +//! `tools/extract_classes.py`. +//! +//! The Python script (Python 2, ~360 lines, ~140 of which are the +//! extraction logic and ~150 the table formatters) walks a MARS XSD and +//! enumerates the node-classification taxonomy: for the four master +//! node types (Application / Resource / Software / Machine) it pulls +//! every `(Class, SubClass)` pair out of the `xs:extension` / fixed- +//! `xs:attribute` chain, attaches the English `xs:documentation`, and +//! renders an asciidoc or HTML table. +//! +//! This module reproduces that walk and both output formats **byte for +//! byte** — the transcode proof is `tests::asciidoc_matches_python_oracle`, +//! which asserts the Rust output equals the cached Python output at +//! `_oracle/classifications.adoc`. +//! +//! # Why transcode it +//! +//! 1. **Removes the Python dependency from the calibration oracle.** The +//! MARS bijection (`docs/MARS-TRANSCODING.md`) no longer needs a +//! `python3` interpreter — `cargo test` is the whole proof. +//! 2. **Seeds the broader XSD → `Class` front-end.** The same walk that +//! extracts classifications is the structural-arm lift for any XSD +//! schema; [`classifications`] is the first consumer, a future +//! `into_classes` the second. +//! 3. **Closes the XSD ↔ TTL bijection.** The classification set this +//! module extracts must equal the `ogit:validation-parameter` enum +//! set the TTL front-end lifts — see +//! `tests::xsd_classes_match_ttl_enum`. +//! +//! Feature-gated behind `xsd` so the default TTL path stays +//! zero-parser-deps. + +use std::collections::BTreeMap; + +use roxmltree::{Document, Node}; + +/// The four MARS master complex types and the node type each anchors. +/// Mirrors the Python `master_types` dict. +const MASTER_TYPES: [(&str, &str); 4] = [ + ("MachineAttributes", "Machine"), + ("ResourceAttributes", "Resource"), + ("SoftwareAttributes", "Software"), + ("ApplicationAttributes", "Application"), +]; + +fn master_type(base: &str) -> Option<&'static str> { + MASTER_TYPES + .iter() + .find(|(b, _)| *b == base) + .map(|(_, t)| *t) +} + +/// A lifted classification record — the Rust counterpart of the Python +/// `parsed_data` dict. `fixed` maps a fixed-attribute *type* name +/// (`"ApplicationClass"`, `"ApplicationSubClass"`, `"MachineClass"`, …) +/// to its fixed *value*, exactly as the script does with +/// `parsed_data[parsed_data["_fixed"]["type"]] = value`. +#[derive(Debug, Clone, Default)] +struct Record { + name: String, + base: String, + node_type: Option, + fixed: BTreeMap, + doc: Vec, +} + +/// The full extraction result: per-node-type element records, ready for +/// table rendering. Keyed by node type (`"Application"` … `"Machine"`). +#[derive(Debug, Default)] +pub struct Classifications { + /// MARS schema version string (the `version` attribute on + /// `xs:schema`, e.g. `"5.3.8"`). + pub version: String, + /// Element records grouped by node type. + elements: BTreeMap>, +} + +impl Classifications { + /// Every `(node_type, class, subclass)` triple extracted, sorted. + /// `subclass` is `None` for the 2-column node types (Resource, + /// Machine). This is the structured surface the TTL cross-check + /// reads. + #[must_use] + pub fn triples(&self) -> Vec<(String, String, Option)> { + let mut out = Vec::new(); + for (node_type, recs) in &self.elements { + let class_key = format!("{node_type}Class"); + let sub_key = format!("{node_type}SubClass"); + for r in recs { + if let Some(class) = r.fixed.get(&class_key) { + out.push(( + node_type.clone(), + class.clone(), + r.fixed.get(&sub_key).cloned(), + )); + } + } + } + out.sort(); + out.dedup(); + out + } + + /// All distinct class+subclass values for a node type — the set + /// that must equal the TTL `ogit:validation-parameter` enum for + /// the same `/attributes/{class,subClass}.ttl` files. + #[must_use] + pub fn value_set(&self, node_type: &str) -> Vec { + let mut set = std::collections::BTreeSet::new(); + let class_key = format!("{node_type}Class"); + let sub_key = format!("{node_type}SubClass"); + if let Some(recs) = self.elements.get(node_type) { + for r in recs { + if let Some(v) = r.fixed.get(&class_key) { + set.insert(v.clone()); + } + if let Some(v) = r.fixed.get(&sub_key) { + set.insert(v.clone()); + } + } + } + set.into_iter().collect() + } +} + +/// Parse a MARS XSD into [`Classifications`]. Faithful transcode of the +/// Python `extract_from_xml`. +/// +/// # Errors +/// +/// Returns `Err` if the XML fails to parse. +pub fn classifications(xsd: &str) -> Result { + let doc = Document::parse(xsd)?; + let root = doc.root_element(); // the xs:schema element + let version = root.attribute("version").unwrap_or_default().to_owned(); + + let mut el_data: Vec = Vec::new(); + let mut ct_data: BTreeMap = BTreeMap::new(); + + for child in root.children().filter(Node::is_element) { + match child.tag_name().name() { + "element" => { + if let Some(rec) = parse_subject(child, /* is_element */ true) { + el_data.push(rec); + } + } + "complexType" => { + // Only complex types whose base IS a master type get a + // node_type (and are therefore stored) — mirrors the + // Python `if parsed_data.has_key("TYPE")` gate. + if let Some(rec) = parse_subject(child, /* is_element */ false) + && rec.node_type.is_some() + { + ct_data.insert(rec.name.clone(), rec); + } + } + _ => {} + } + } + + // Post-process phase II: an element whose own base is an + // intermediate complex type (not a master type) inherits that + // type's node_type + class-level fixed value. + for el in &mut el_data { + if el.node_type.is_none() + && let Some(base_rec) = ct_data.get(&el.base) + { + el.node_type = base_rec.node_type.clone(); + for (k, v) in &base_rec.fixed { + el.fixed.insert(k.clone(), v.clone()); + } + } + } + + let mut elements: BTreeMap> = BTreeMap::new(); + for el in el_data { + if let Some(nt) = el.node_type.clone() { + elements.entry(nt).or_default().push(el); + } + } + + Ok(Classifications { version, elements }) +} + +/// Shared parser for both `xs:element` and `xs:complexType` subjects — +/// the Python `parse_element_data` / `parse_complex_type` are nearly +/// identical; the only difference is that a complex type sets its +/// node_type *only* when its base is a master type, whereas an element +/// also records its fixed value unconditionally. +fn parse_subject(node: Node, is_element: bool) -> Option { + // First descendant xs:extension. + let ext = node + .descendants() + .find(|n| n.is_element() && n.tag_name().name() == "extension")?; + let base = strip_aae(ext.attribute("base").unwrap_or_default()); + + // Last direct xs:attribute child wins (mirrors the Python loop that + // overwrites `_fixed` each iteration). + let mut fixed_name = None; + let mut fixed_type = None; + let mut fixed_value = None; + for attr_el in ext.children().filter(Node::is_element) { + if attr_el.tag_name().name() == "attribute" { + fixed_name = Some(attr_el.attribute("name").unwrap_or_default().to_owned()); + fixed_type = Some(strip_aae(attr_el.attribute("type").unwrap_or_default())); + fixed_value = Some(attr_el.attribute("fixed").unwrap_or_default().to_owned()); + } + } + let (_fixed_name, fixed_type, fixed_value) = (fixed_name?, fixed_type?, fixed_value?); + + // English documentation: every descendant xs:documentation whose + // xml:lang is absent or "en". + let mut docs = Vec::new(); + for doc_el in node + .descendants() + .filter(|n| n.is_element() && n.tag_name().name() == "documentation") + { + let lang = doc_el + .attributes() + .find(|a| a.name() == "lang") + .map(|a| a.value()); + if lang.is_none() || lang == Some("en") { + docs.push(text_of(doc_el)); + } + } + + let mut rec = Record { + name: node.attribute("name").unwrap_or_default().to_owned(), + base: base.clone(), + node_type: None, + fixed: BTreeMap::new(), + doc: docs, + }; + + let is_master = master_type(&base); + if let Some(t) = is_master { + rec.node_type = Some(t.to_owned()); + rec.fixed.insert(fixed_type.clone(), fixed_value.clone()); + } + if is_element && is_master.is_none() { + // Element extending an intermediate type: still records its own + // fixed value (the SubClass) even without a node_type yet. + rec.fixed.insert(fixed_type, fixed_value); + } + + Some(rec) +} + +/// Concatenate the direct text-node children of an element, verbatim — +/// the Python `getXMLText`. Preserves the XSD documentation's internal +/// whitespace exactly (load-bearing for the byte-match). +fn text_of(node: Node) -> String { + node.children() + .filter(Node::is_text) + .filter_map(|c| c.text()) + .collect() +} + +fn strip_aae(s: &str) -> String { + s.strip_prefix("aae:").unwrap_or(s).to_owned() +} + +// ───────────────────────────────────────── output formatters ── + +/// Render the asciidoc table set, byte-for-byte equal to the Python +/// `-F asciidoc` output. `revdate` is the `:revdate:` value (the Python +/// script uses `datetime.now()`, non-deterministic; here it is a +/// parameter so the output is reproducible and testable). +#[must_use] +pub fn to_asciidoc(c: &Classifications, revdate: &str) -> String { + let mut out = String::new(); + // printAsciiDocTitle + printAsciiDocHeader + out.push_str(&format!( + "= Node classifications from MARS Schema {}\n", + c.version + )); + out.push_str(":toc:\n"); + out.push_str(&format!(":revdate: {revdate}\n")); + out.push_str("\n<<<\n\n"); + out.push_str( + "[NOTE]\n====\nThe MARS Schema uses a different versioning cycle from HIRO Product. \ + It is expected to see the two versions deviate from each other.\n\nThis list was \ + last updated on: *{revdate}*\n====\n\n\n", + ); + adoc_table3(&mut out, c, "Application"); + adoc_table2(&mut out, c, "Resource"); + adoc_table3(&mut out, c, "Software"); + adoc_table2(&mut out, c, "Machine"); + // printAsciiDocFooter — a single trailing `print ""`. + out.push('\n'); + out +} + +fn adoc_table3(out: &mut String, c: &Classifications, node_type: &str) { + let class_key = format!("{node_type}Class"); + let sub_key = format!("{node_type}SubClass"); + out.push_str(&format!("== {node_type} Node Classifications\n\n")); + out.push_str("[cols=\"1,1,3\", options=\"header\"]\n"); + out.push_str("|===\n"); + out.push_str(&format!("|{class_key}|{sub_key}|Description\n")); + let (by_class, by_name) = group_3col(c, node_type, &class_key, &sub_key); + for (cl, subs) in &by_class { + for sub in subs { + if let Some(doc) = by_name.get(sub) { + out.push_str(&format!("|{cl}|{sub}|{}\n", doc.join("
"))); + } + } + } + out.push_str("|===\n\n"); +} + +fn adoc_table2(out: &mut String, c: &Classifications, node_type: &str) { + let class_key = format!("{node_type}Class"); + out.push_str(&format!("== {node_type} Node Classifications\n\n")); + out.push_str("[cols=\"1,5\", options=\"header\"]\n"); + out.push_str("|===\n"); + out.push_str(&format!("|{class_key}|Description\n")); + let by_name = group_2col(c, node_type, &class_key); + for (cl, doc) in &by_name { + out.push_str(&format!("|{cl}|{}\n", doc.join("
"))); + } + out.push_str("|===\n\n"); +} + +/// Build the sorted class→subclasses ordering + the subclass-name→doc +/// lookup for a 3-column node type. +fn group_3col( + c: &Classifications, + node_type: &str, + class_key: &str, + sub_key: &str, +) -> (BTreeMap>, BTreeMap>) { + let mut by_class: BTreeMap> = BTreeMap::new(); + let mut by_name: BTreeMap> = BTreeMap::new(); + if let Some(recs) = c.elements.get(node_type) { + for r in recs { + if let (Some(cl), Some(sub)) = (r.fixed.get(class_key), r.fixed.get(sub_key)) { + by_class.entry(cl.clone()).or_default().push(sub.clone()); + by_name.insert(r.name.clone(), r.doc.clone()); + } + } + } + for subs in by_class.values_mut() { + subs.sort(); + subs.dedup(); + } + (by_class, by_name) +} + +/// Build the sorted class-name→doc lookup for a 2-column node type. +fn group_2col( + c: &Classifications, + node_type: &str, + class_key: &str, +) -> BTreeMap> { + let mut by_name: BTreeMap> = BTreeMap::new(); + if let Some(recs) = c.elements.get(node_type) { + for r in recs { + if r.fixed.contains_key(class_key) { + by_name.insert(r.name.clone(), r.doc.clone()); + } + } + } + by_name +} + +// ───────────────────────────────────────────────── tests ── + +#[cfg(test)] +mod tests { + use super::*; + + const XSD: &str = + include_str!("../../../vocab/imports/ogit/NTO/MARS/_oracle/MARSSchema2015.xsd"); + const ORACLE_ADOC: &str = + include_str!("../../../vocab/imports/ogit/NTO/MARS/_oracle/classifications.adoc"); + + #[test] + fn parses_version() { + let c = classifications(XSD).expect("parse"); + assert_eq!(c.version, "5.3.8"); + } + + #[test] + fn extracts_expected_counts() { + let c = classifications(XSD).expect("parse"); + // From PROVENANCE.md / the Python oracle: + // Application 7 classes × 50 subclass pairs + // Resource 19 classes + // Software 40 classes × 336 subclass pairs + // Machine 11 classes + let app: Vec<_> = c + .triples() + .into_iter() + .filter(|(n, ..)| n == "Application") + .collect(); + let mach: Vec<_> = c + .triples() + .into_iter() + .filter(|(n, ..)| n == "Machine") + .collect(); + let app_classes: std::collections::BTreeSet<_> = + app.iter().map(|(_, cl, _)| cl.clone()).collect(); + let mach_classes: std::collections::BTreeSet<_> = + mach.iter().map(|(_, cl, _)| cl.clone()).collect(); + assert_eq!(app_classes.len(), 7, "Application classes"); + assert_eq!(mach_classes.len(), 11, "Machine classes"); + } + + /// **The transcode proof.** The Rust asciidoc output must be + /// byte-for-byte equal to the cached Python `-F asciidoc` output. + /// The cached file was generated with `:revdate: 22-Jun-2026`, so + /// we pass the same value. + #[test] + fn asciidoc_matches_python_oracle() { + let c = classifications(XSD).expect("parse"); + let rust = to_asciidoc(&c, "22-Jun-2026"); + if rust != ORACLE_ADOC { + // Pinpoint the first divergence for a useful failure message. + let a: Vec<&str> = rust.lines().collect(); + let b: Vec<&str> = ORACLE_ADOC.lines().collect(); + for (i, (la, lb)) in a.iter().zip(b.iter()).enumerate() { + assert_eq!(la, lb, "first divergence at line {}", i + 1); + } + assert_eq!( + a.len(), + b.len(), + "line count differs (rust {} vs oracle {})", + a.len(), + b.len() + ); + } + } + + /// **Closes the XSD ↔ TTL bijection.** The XSD-extracted + /// Application class+subclass value set must equal what the TTL + /// front-end lifts from `Application/attributes/{class,subClass}.ttl`. + #[test] + fn xsd_classes_match_ttl_enum() { + use crate::TtlDeclaration; + use crate::ttl::parse_file; + + let c = classifications(XSD).expect("parse"); + let xsd_values: std::collections::BTreeSet = + c.value_set("Application").into_iter().collect(); + + const CLASS_TTL: &str = + include_str!("../../../vocab/imports/ogit/NTO/MARS/Application/attributes/class.ttl"); + const SUBCLASS_TTL: &str = include_str!( + "../../../vocab/imports/ogit/NTO/MARS/Application/attributes/subClass.ttl" + ); + let mut ttl_values = std::collections::BTreeSet::new(); + for ttl in [CLASS_TTL, SUBCLASS_TTL] { + let TtlDeclaration::DatatypeAttribute(a) = parse_file(ttl).expect("parse ttl") else { + panic!("expected datatype attribute"); + }; + for v in a.fixed_enum_values().expect("fixed enum") { + ttl_values.insert(v); + } + } + + // Full bidirectional equality — every TTL value in the XSD set + // AND every XSD value in the TTL set. This is the bijection the + // MARS-TRANSCODING.md §2 "queued" note asked for. + assert_eq!( + xsd_values, ttl_values, + "XSD-extracted Application value set differs from TTL enum set" + ); + } +} diff --git a/docs/MARS-TRANSCODING.md b/docs/MARS-TRANSCODING.md index bdfd7b1..f8dff45 100644 --- a/docs/MARS-TRANSCODING.md +++ b/docs/MARS-TRANSCODING.md @@ -92,15 +92,24 @@ classification taxonomy: | Direction | Oracle role | |---|---| -| XSD → classifications | `extract_classes.py -s MARSSchema2015.xsd -F asciidoc` enumerates every `(class, subclass)` pair | +| XSD → classifications (Python) | `extract_classes.py -s MARSSchema2015.xsd -F asciidoc` enumerates every `(class, subclass)` pair | +| XSD → classifications (Rust) | `ogar-from-schema::xsd::classifications()` — a **faithful transcode** of the Python script; `xsd::to_asciidoc()` reproduces the output **byte-for-byte** (`xsd::tests::asciidoc_matches_python_oracle`) | | TTL → classifications | OGIT `Application/attributes/class.ttl` etc. carry the same set in `ogit:validation-parameter` | | OGAR → classifications | `ogar-from-schema::ttl::AttributeDecl::fixed_enum_values()` lifts them as `EnumSource::Static` | -The three sets must be byte-equal modulo encoding artefacts (HTML/asciidoc -escape vs raw). The agreement test -(`ttl::tests::application_class_values_appear_in_xsd_oracle`) is the v0 -witness; expansion to full set-equality (XSD set == TTL set, no missing, -no extra) is queued. +**The bijection is now closed (was queued).** `xsd::tests::xsd_classes_match_ttl_enum` +asserts **full bidirectional set-equality** — every XSD-extracted +Application value is in the TTL enum AND every TTL enum value is in the +XSD set. The earlier one-directional membership test +(`ttl::tests::application_class_values_appear_in_xsd_oracle`) remains as +the lighter witness that runs without the `xsd` feature. + +**The Python dependency is gone.** Because `ogar-from-schema::xsd` is a +byte-faithful Rust transcode, the calibration no longer needs a +`python3` interpreter — `cargo test --features xsd` is the whole proof. +The Python `extract_classes.py` stays vendored in `_oracle/` as the +provenance witness (the thing the transcode was proven against), not as +a runtime dependency. Counts at MARS Schema 5.3.8: