From 9f7eaeba7fa22e87b0225e8c54c3ab92c9b5f9a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 17:46:39 +0000 Subject: [PATCH] Foreign-consumer SDK: Python + C# materialization emitters Two new crates in the adapter family (operator directive: give C# and Python consumers the plug-and-play surface before the pattern dilutes). Each emits ONE deterministic self-contained source file materializing: all 84 class ids + the canon-high compose helper, the domain action tables (generic over domain_tables() -- future domains land on regeneration), a PURE target-language mirror of resolve_hotplug with the same five drift arms (the confirmation loop runs identically in the foreign language), and the V3 4+12 facet decoder (16 B = classid LE + 6x8:8, E-V3-FACET-4-PLUS-12) as the substrate read surface. Verification is compile+RUN parity in both languages against ONE Rust ground-truth dump (115 lines): python3 py_compile + import + diff; dotnet build + run + diff (offline, no NuGet). Facet bytes built in Rust decode field-identically in both. 4/4 integration tests; fmt+clippy -D warnings clean; ogar-vocab sibling untouched. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_016b33swuXE23hKtqxsHu9p1 --- Cargo.toml | 2 + crates/ogar-adapter-csharp/Cargo.toml | 18 + crates/ogar-adapter-csharp/examples/emit.rs | 32 + crates/ogar-adapter-csharp/src/lib.rs | 575 +++++++++++++++ crates/ogar-adapter-csharp/tests/parity.rs | 174 +++++ crates/ogar-adapter-python/Cargo.toml | 12 + crates/ogar-adapter-python/examples/emit.rs | 31 + crates/ogar-adapter-python/src/lib.rs | 730 ++++++++++++++++++++ crates/ogar-adapter-python/tests/parity.rs | 155 +++++ 9 files changed, 1729 insertions(+) create mode 100644 crates/ogar-adapter-csharp/Cargo.toml create mode 100644 crates/ogar-adapter-csharp/examples/emit.rs create mode 100644 crates/ogar-adapter-csharp/src/lib.rs create mode 100644 crates/ogar-adapter-csharp/tests/parity.rs create mode 100644 crates/ogar-adapter-python/Cargo.toml create mode 100644 crates/ogar-adapter-python/examples/emit.rs create mode 100644 crates/ogar-adapter-python/src/lib.rs create mode 100644 crates/ogar-adapter-python/tests/parity.rs diff --git a/Cargo.toml b/Cargo.toml index c9da03d..74f5f59 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,8 @@ members = [ "crates/ogar-class-view", "crates/ogar-render-askama", "crates/ogar-fma-skeleton", + "crates/ogar-adapter-python", + "crates/ogar-adapter-csharp", ] [workspace.package] diff --git a/crates/ogar-adapter-csharp/Cargo.toml b/crates/ogar-adapter-csharp/Cargo.toml new file mode 100644 index 0000000..16554c3 --- /dev/null +++ b/crates/ogar-adapter-csharp/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "ogar-adapter-csharp" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +rust-version.workspace = true +description = "Materializes the OGAR capability surface (class ids, domain action tables, hot-plug resolution, V3 facet decoder) as a self-contained generated C# class library for foreign consumers -- the plug-and-play pattern without a Rust/OGAR dependency." + +[dependencies] +ogar-vocab = { path = "../ogar-vocab" } + +[dev-dependencies] +# Reuses the ONE ground-truth dump generator + comparator so the +# Python and C# verification loops can never silently diverge in what +# "correct" means (see ogar_adapter_python::ground_truth). +ogar-adapter-python = { path = "../ogar-adapter-python" } diff --git a/crates/ogar-adapter-csharp/examples/emit.rs b/crates/ogar-adapter-csharp/examples/emit.rs new file mode 100644 index 0000000..9783f93 --- /dev/null +++ b/crates/ogar-adapter-csharp/examples/emit.rs @@ -0,0 +1,32 @@ +//! Write the generated C# class library to a file. +//! +//! Usage: +//! ```text +//! cargo run -p ogar-adapter-csharp --example emit -- [namespace] +//! ``` +//! `namespace` defaults to `Ogar.CapabilitySurface` and becomes the +//! emitted file's real `namespace` declaration (see +//! [`ogar_adapter_csharp::emit_csharp`]). + +use std::env; +use std::fs; +use std::process::ExitCode; + +fn main() -> ExitCode { + let mut args = env::args().skip(1); + let Some(output_path) = args.next() else { + eprintln!("usage: emit [namespace]"); + return ExitCode::from(2); + }; + let namespace = args + .next() + .unwrap_or_else(|| "Ogar.CapabilitySurface".to_string()); + + let content = ogar_adapter_csharp::emit_csharp(&namespace); + if let Err(e) = fs::write(&output_path, content) { + eprintln!("failed to write {output_path}: {e}"); + return ExitCode::FAILURE; + } + eprintln!("wrote {output_path} (namespace {namespace})"); + ExitCode::SUCCESS +} diff --git a/crates/ogar-adapter-csharp/src/lib.rs b/crates/ogar-adapter-csharp/src/lib.rs new file mode 100644 index 0000000..03a2630 --- /dev/null +++ b/crates/ogar-adapter-csharp/src/lib.rs @@ -0,0 +1,575 @@ +//! `ogar-adapter-csharp` — materializes the OGAR capability surface as a +//! self-contained generated C# class library for foreign consumers. +//! +//! Companion to `ogar-adapter-python`; together they give a non-Rust +//! consumer the same "hot-plug" plug-and-play pattern documented in +//! `.claude/knowledge/hotplug-consumer-migration.md` — without linking +//! against `ogar-vocab` at all. [`emit_csharp`] renders ONE `.cs` file +//! (compile-time artifact, zero runtime serialization) carrying: +//! +//! 1. **`ClassIds`** — every [`ogar_vocab::class_ids::ALL`] entry, plus +//! `ComposeRenderClassid` / `ConceptOf` / `AppOf` (the canon-high +//! classid composition helpers mirroring [`ogar_vocab::app`]). +//! 2. **Domain action tables** — every +//! [`ogar_vocab::capability_registry::domain_tables`] entry +//! generically (so a future domain lands on regeneration with zero +//! code change here), plus the FULL OCR specs from +//! [`ogar_vocab::ocr_actions::ocr_actions`] (capability, subject +//! concept + classid, typed params with mandatory flags, produces). +//! 3. **`HotPlug` + `ResolveHotplug`** — a pure C# mirror of +//! [`ogar_vocab::capability_registry::resolve_hotplug`], same five +//! drift arms (`UnknownClassid` / `NoCapabilitiesFor` / +//! `UnexpectedConsumer` / `Uncovered` / `Undeclared`), same check +//! order. +//! 4. **`Facet`** — the V3 4+12 content-blind facet read side: 16 bytes +//! -> `(classid u32 LE, six (lo, hi) byte pairs)`, decoded via +//! `System.Buffers.Binary.BinaryPrimitives` over `Span` (pure +//! BCL, no NuGet package). See OGAR `CLAUDE.md` P0 "THE CANONICAL +//! GUID" / `E-V3-FACET-4-PLUS-12`, and `ruff_spo_address::Facet` +//! (the producer-side Rust twin this mirrors byte-for-byte: +//! `facet_classid(4 LE) | 6 x (lo:hi)`). +//! +//! Output is deterministic and self-contained: no `PackageReference`, +//! pure BCL, so the emitted file builds against `net8.0` offline. +//! +//! The emitted type is a plain library (`OgarCapabilitySurface` static +//! class + supporting records) with NO `Main` entry point — a +//! consumer's own `Program.cs` calls `OgarCapabilitySurface.Dump()` or +//! `ResolveHotplug(...)` directly, matching how `ogar-adapter-python`'s +//! module is meant to be imported, not executed standalone (though its +//! `if __name__ == "__main__":` guard is also provided for convenience). +//! +//! See `ogar_adapter_python::ground_truth` for the canonical +//! verification-dump format and the ONE comparison function +//! (`assert_dump_matches`) this crate's own tests reuse via a +//! `[dev-dependencies]` path dependency on `ogar-adapter-python`. + +#![forbid(unsafe_code)] +#![warn(missing_docs)] + +use ogar_vocab::capability_registry::domain_tables; +use ogar_vocab::class_ids; +use ogar_vocab::ocr_actions::ocr_actions; + +// ───────────────────────────────────────────────────────────────────── +// Public API +// ───────────────────────────────────────────────────────────────────── + +/// Render the self-contained generated C# class library. +/// +/// `namespace` becomes the emitted file's `namespace` declaration (a +/// real, functional parameter — unlike `ogar-adapter-python`'s +/// `module_name`, C# source genuinely declares its own namespace). +/// +/// # Determinism +/// +/// Calling this twice against the same `ogar-vocab` state yields +/// byte-identical output — every table is emitted in the source data's +/// own stable order ([`ogar_vocab::class_ids::ALL`]'s declaration +/// order, [`ocr_actions`]'s table order, [`domain_tables`]'s +/// registration order), with no hashmap-iteration nondeterminism. +#[must_use] +pub fn emit_csharp(namespace: &str) -> String { + let mut out = CS_TEMPLATE.replace("@@NAMESPACE@@", namespace); + out = out.replace("@@CLASS_IDS@@", &cs_class_ids_block()); + out = out.replace("@@OCR_ACTIONS@@", &cs_ocr_actions_block()); + out = out.replace( + "@@OCR_EXPECTED_EXECUTORS@@", + &cs_str_array_inline(ogar_vocab::ocr_actions::OCR_EXPECTED_EXECUTORS), + ); + out = out.replace( + "@@OCR_SUBJECT_CLASSIDS@@", + &cs_hex_array_inline(ogar_vocab::ocr_actions::OCR_SUBJECT_CLASSIDS), + ); + out = out.replace("@@DOMAIN_TABLES@@", &cs_domain_tables_block()); + out +} + +// ───────────────────────────────────────────────────────────────────── +// Dynamic block builders — each pulls straight from live `ogar_vocab` +// data, never a hand-transcribed copy. +// ───────────────────────────────────────────────────────────────────── + +fn cs_class_ids_block() -> String { + let mut s = String::new(); + for &(name, id) in class_ids::ALL { + s.push_str(&format!(" {{ {name:?}, 0x{id:04X} }},\n")); + } + s +} + +fn cs_ocr_actions_block() -> String { + let mut s = String::new(); + for spec in ocr_actions() { + let subject_concept_name = spec + .def + .object_class + .rsplit('/') + .next() + .unwrap_or_default() + .to_string(); + let subject_classid = + ogar_vocab::canonical_concept_id(&subject_concept_name).unwrap_or_default(); + let params: Vec = spec + .params + .iter() + .map(|p| format!("new ActionParam({:?}, {})", p.name, cs_bool(p.mandatory))) + .collect(); + let produces: Vec = spec.produces.iter().map(|p| format!("{p:?}")).collect(); + s.push_str(&format!( + " new OcrActionSpec(\n Capability: {:?},\n SubjectConcept: {subject_concept_name:?},\n SubjectClassId: 0x{subject_classid:04X},\n Params: new ActionParam[] {{ {} }},\n Produces: new string[] {{ {} }}),\n", + spec.def.predicate, + params.join(", "), + produces.join(", "), + )); + } + s +} + +fn cs_domain_tables_block() -> String { + let mut s = String::new(); + for table in domain_tables() { + let execs: Vec = table + .expected_executors + .iter() + .map(|e| format!("{e:?}")) + .collect(); + let entries: Vec = (table.entries)() + .iter() + .map(|(cap, id)| format!("({cap:?}, 0x{id:04X})")) + .collect(); + s.push_str(&format!( + " new DomainTable(\n Domain: {:?},\n ExpectedExecutors: new string[] {{ {} }},\n Entries: new (string Capability, int SubjectClassId)[] {{ {} }}),\n", + table.domain, + execs.join(", "), + entries.join(", "), + )); + } + s +} + +fn cs_str_array_inline(items: &[&str]) -> String { + items + .iter() + .map(|s| format!("{s:?}")) + .collect::>() + .join(", ") +} + +fn cs_hex_array_inline(items: &[u16]) -> String { + items + .iter() + .map(|id| format!("0x{id:04X}")) + .collect::>() + .join(", ") +} + +fn cs_bool(b: bool) -> &'static str { + if b { "true" } else { "false" } +} + +// ───────────────────────────────────────────────────────────────────── +// The static C# template. `@@…@@` markers are the data seams filled in +// by `emit_csharp` above. +// ───────────────────────────────────────────────────────────────────── + +const CS_TEMPLATE: &str = r##"// GENERATED by ogar-adapter-csharp -- do not edit; regenerate from ogar-vocab. +// +// Materializes the OGAR capability surface for a foreign C# consumer: +// canonical class ids, the domain action tables (OCR capability specs +// today; any future `domain_tables()` entry lands here automatically on +// regeneration), hot-plug resolution (`ResolveHotplug` mirror, same five +// drift arms), and the V3 4+12 facet decoder (classid(4 LE) + 12-byte +// payload as six (lo, hi) rails). +// +// Canon: OGAR CLAUDE.md P0 "THE CANONICAL GUID" + E-V3-FACET-4-PLUS-12 +// (classid: u32 = [hi u16 CANON concept][lo u16 APP render prefix]). +// Pattern: .claude/knowledge/hotplug-consumer-migration.md. + +using System; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.Linq; + +namespace @@NAMESPACE@@; + +/// One parameter of a capability's typed I/O signature. +public sealed record ActionParam(string Name, bool Mandatory); + +/// +/// One OCR capability -- mirrors ogar_vocab::ocr_actions::OcrActionSpec. +/// +public sealed record OcrActionSpec( + string Capability, + string SubjectConcept, + int SubjectClassId, + ActionParam[] Params, + string[] Produces); + +/// +/// One authoritative domain action table -- the generic hot-plug join +/// surface. Mirrors ogar_vocab::capability_registry::DomainTable. +/// +public sealed record DomainTable( + string Domain, + string[] ExpectedExecutors, + (string Capability, int SubjectClassId)[] Entries); + +/// +/// A consumer's hot-plug declaration. Mirrors +/// lance_graph_contract::hotplug::HotPlug. +/// +public sealed record HotPlug(string Consumer, int[] ClassIds, string[] Covered); + +/// Base class for every failure arm. +public abstract class HotplugDrift : Exception +{ + /// Construct with the formatted drift message. + protected HotplugDrift(string message) : base(message) { } +} + +/// A hot-plugged classid is not minted in the codebook. +public sealed class UnknownClassid : HotplugDrift +{ + /// The unminted classid. + public int ClassId { get; } + + /// Construct from the offending classid. + public UnknownClassid(int classId) : base($"hot-plugged classid 0x{classId:X4} is not minted") + => ClassId = classId; +} + +/// A hot-plugged classid resolves to no declared capability. +public sealed class NoCapabilitiesFor : HotplugDrift +{ + /// The classid with no declared capability. + public int ClassId { get; } + + /// Construct from the offending classid. + public NoCapabilitiesFor(int classId) : base($"classid 0x{classId:X4} resolves to no declared capability") + => ClassId = classId; +} + +/// The consumer is not an expected executor for a contributing table. +public sealed class UnexpectedConsumer : HotplugDrift +{ + /// The unexpected consumer name. + public string Consumer { get; } + + /// Construct from the offending consumer name. + public UnexpectedConsumer(string consumer) : base($"consumer `{consumer}` is not an expected executor") + => Consumer = consumer; +} + +/// A declared capability has no consumer arm. +public sealed class Uncovered : HotplugDrift +{ + /// The uncovered capability name. + public string Capability { get; } + + /// Construct from the offending capability name. + public Uncovered(string capability) : base($"declared capability `{capability}` has no consumer arm") + => Capability = capability; +} + +/// The consumer covers a capability the authority does not declare. +public sealed class Undeclared : HotplugDrift +{ + /// The undeclared capability name. + public string Capability { get; } + + /// Construct from the offending capability name. + public Undeclared(string capability) : base($"consumer covers `{capability}` which is not declared") + => Capability = capability; +} + +/// +/// The V3 4+12 content-blind facet: classid(4 LE) + 6x(lo:hi) +/// rails, byte-identical to ruff_spo_address::Facet / +/// lance_graph_contract::facet::FacetCascade. +/// +/// Canon: OGAR CLAUDE.md E-V3-FACET-4-PLUS-12 -- the 12-byte +/// payload is a content-blind byte register the classid's ClassView +/// projects; here it is read back as six (lo, hi) byte pairs +/// (u8:u8, NEVER widened to u16/u24). +/// +public sealed record Facet(uint ClassId, byte[] IsAChain, byte[] PartOfChain) +{ + /// The number of (part_of:is_a) cascade tiers (6). + public const int Tiers = 6; + + /// Decode 16 bytes -> (classid u32 LE, six (lo, hi) pairs). + public static Facet FromBytes(ReadOnlySpan data) + { + if (data.Length != 16) + throw new ArgumentException($"facet must be exactly 16 bytes, got {data.Length}"); + uint classId = BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(0, 4)); + var isA = new byte[Tiers]; + var partOf = new byte[Tiers]; + for (int t = 0; t < Tiers; t++) + { + isA[t] = data[4 + 2 * t]; + partOf[t] = data[5 + 2 * t]; + } + return new Facet(classId, isA, partOf); + } + + /// Encode back to the 16-byte wire form (round-trip inverse). + public byte[] ToBytes() + { + var outBytes = new byte[16]; + BinaryPrimitives.WriteUInt32LittleEndian(outBytes.AsSpan(0, 4), ClassId); + for (int t = 0; t < Tiers; t++) + { + outBytes[4 + 2 * t] = IsAChain[t]; + outBytes[5 + 2 * t] = PartOfChain[t]; + } + return outBytes; + } +} + +/// +/// Materializes the OGAR capability surface: class ids, domain action +/// tables, hot-plug resolution, and the facet decoder. See the file +/// header for the full doc. No Main here by design -- a +/// consumer's own entry point calls into this static class. +/// +public static class OgarCapabilitySurface +{ + // ============================================================ + // 1. ClassIds -- canonical concept name -> u16 codebook id + // ============================================================ + + /// Every promoted OGAR concept, by canonical name. + public static readonly IReadOnlyDictionary ClassIds = new Dictionary + { +@@CLASS_IDS@@ }; + + private static readonly IReadOnlyDictionary IdToName = + ClassIds.ToDictionary(kv => kv.Value, kv => kv.Key); + + /// + /// Compose a full render classid: (concept << 16) | app. + /// Canon (OGAR CLAUDE.md P0 "THE CANONICAL GUID", operator-locked + /// 2026-07-02): classid: u32 = [hi u16 CANON concept][lo u16 APP + /// / render prefix]. Mirrors ogar_vocab::app::render_classid + /// (parameter order flipped to concept-first per this module's contract). + /// + public static int ComposeRenderClassid(int concept, int app) => + ((concept & 0xFFFF) << 16) | (app & 0xFFFF); + + /// The CANON concept half (high u16) of a full classid. + public static int ConceptOf(int classid) => (classid >> 16) & 0xFFFF; + + /// The APP / render-prefix half (low u16) of a full classid. + public static int AppOf(int classid) => classid & 0xFFFF; + + // ============================================================ + // 2. Domain action tables + // ============================================================ + + /// The tesseract-rs OCR capability surface, FULL specs. + public static readonly OcrActionSpec[] OcrActions = + { +@@OCR_ACTIONS@@ }; + + /// Executors the OCR table expects to register against it. + public static readonly string[] OcrExpectedExecutors = { @@OCR_EXPECTED_EXECUTORS@@ }; + + /// The distinct subject classids the OCR table binds. + public static readonly int[] OcrSubjectClassIds = { @@OCR_SUBJECT_CLASSIDS@@ }; + + /// + /// Every OCR capability name, in table order -- matches + /// ogar_vocab::ocr_actions::OCR_ACTION_NAMES. Used below as + /// the "everything covered" scenario for the hot-plug verification + /// dump. + /// + public static readonly string[] OcrExpectedCoveredAll = + OcrActions.Select(a => a.Capability).ToArray(); + + /// + /// Every registered authoritative domain table -- the generic + /// hot-plug join surface. Mirrors + /// ogar_vocab::capability_registry::domain_tables(). + /// + public static readonly DomainTable[] DomainTables = + { +@@DOMAIN_TABLES@@ }; + + // ============================================================ + // 3. HotPlug + ResolveHotplug + // ============================================================ + + /// + /// Pure C# mirror of + /// ogar_vocab::capability_registry::resolve_hotplug. + /// + /// Checks, in order: every classid minted (); + /// per contributing domain table, the consumer is an expected + /// executor (); every hot-plugged + /// classid contributes at least one capability + /// (); coverage both directions + /// ( / ). + /// + public static (List<(string Name, int ClassId)> Concepts, List Capabilities) ResolveHotplug( + string consumer, IReadOnlyList classids, IReadOnlyList covered) + { + var concepts = new List<(string, int)>(); + foreach (var cid in classids) + { + if (!IdToName.TryGetValue(cid, out var name)) + throw new UnknownClassid(cid); + concepts.Add((name, cid)); + } + + // BTreeMap-equivalent: distinct classids, ascending, count of + // contributing (capability, subject) rows seen so far. + var uniqueSorted = classids.Distinct().OrderBy(x => x).ToList(); + var contributed = uniqueSorted.ToDictionary(id => id, _ => 0); + var capabilities = new List(); + + foreach (var table in DomainTables) + { + bool tableContributes = false; + foreach (var (cap, subject) in table.Entries) + { + if (contributed.ContainsKey(subject)) + { + contributed[subject]++; + tableContributes = true; + capabilities.Add(cap); + } + } + if (tableContributes && !table.ExpectedExecutors.Contains(consumer)) + throw new UnexpectedConsumer(consumer); + } + + foreach (var cid in uniqueSorted) + { + if (contributed[cid] == 0) + throw new NoCapabilitiesFor(cid); + } + + capabilities = capabilities.Distinct().OrderBy(c => c, StringComparer.Ordinal).ToList(); + + var coveredSet = new HashSet(covered); + foreach (var cap in capabilities) + { + if (!coveredSet.Contains(cap)) + throw new Uncovered(cap); + } + + var capabilitiesSet = new HashSet(capabilities); + foreach (var cap in covered) + { + if (!capabilitiesSet.Contains(cap)) + throw new Undeclared(cap); + } + + return (concepts, capabilities); + } + + // ============================================================ + // Verification dump -- the canonical text format shared with the + // ogar-adapter-python emission. See ogar_adapter_python::ground_truth + // on the Rust side for the ground-truth generator + comparator (the + // SAME function verifies both this class's Dump() and the Python + // emission's dump()). + // ============================================================ + + /// Render the canonical verification dump (see class doc). + public static string Dump() + { + var lines = new List(); + + lines.Add("CLASS_IDS"); + foreach (var kv in ClassIds.OrderBy(kv => kv.Key, StringComparer.Ordinal)) + lines.Add($"{kv.Key}={kv.Value:X4}"); + lines.Add("END_CLASS_IDS"); + + lines.Add("OCR_ACTIONS"); + foreach (var a in OcrActions) + { + var paramsStr = string.Join(",", a.Params.Select(p => $"{p.Name}:{(p.Mandatory ? 1 : 0)}")); + var producesStr = string.Join(",", a.Produces); + lines.Add($"{a.Capability}|{a.SubjectConcept}|{a.SubjectClassId:X4}|{paramsStr}|{producesStr}"); + } + lines.Add("END_OCR_ACTIONS"); + + lines.Add("DOMAIN_TABLES"); + foreach (var t in DomainTables) + { + var execs = string.Join(",", t.ExpectedExecutors); + var entries = string.Join(",", t.Entries.Select(e => $"{e.Capability}:{e.SubjectClassId:X4}")); + lines.Add($"{t.Domain}|{execs}|{entries}"); + } + lines.Add("END_DOMAIN_TABLES"); + + lines.Add("HOTPLUG_GREEN"); + var (concepts, capabilities) = ResolveHotplug("tesseract-ogar", OcrSubjectClassIds, OcrExpectedCoveredAll); + lines.Add("CONCEPTS=" + string.Join(",", concepts.Select(c => $"{c.Name}:{c.ClassId:X4}"))); + lines.Add("CAPABILITIES=" + string.Join(",", capabilities)); + lines.Add("END_HOTPLUG_GREEN"); + + lines.Add("HOTPLUG_DRIFT"); + try + { + ResolveHotplug("tesseract-ogar", new[] { 0xFFFE }, Array.Empty()); + throw new Exception("expected UnknownClassid"); + } + catch (UnknownClassid e) { lines.Add($"UnknownClassid={e.ClassId:X4}"); } + + try + { + ResolveHotplug("tesseract-ogar", new[] { ClassIds["patient"] }, Array.Empty()); + throw new Exception("expected NoCapabilitiesFor"); + } + catch (NoCapabilitiesFor e) { lines.Add($"NoCapabilitiesFor={e.ClassId:X4}"); } + + try + { + ResolveHotplug("stranger", OcrSubjectClassIds, OcrExpectedCoveredAll); + throw new Exception("expected UnexpectedConsumer"); + } + catch (UnexpectedConsumer e) { lines.Add($"UnexpectedConsumer={e.Consumer}"); } + + var missing = OcrExpectedCoveredAll.ToList(); + missing.RemoveAt(missing.Count - 1); + try + { + ResolveHotplug("tesseract-ogar", OcrSubjectClassIds, missing); + throw new Exception("expected Uncovered"); + } + catch (Uncovered e) { lines.Add($"Uncovered={e.Capability}"); } + + var extra = OcrExpectedCoveredAll.ToList(); + extra.Add("does_not_exist"); + try + { + ResolveHotplug("tesseract-ogar", OcrSubjectClassIds, extra); + throw new Exception("expected Undeclared"); + } + catch (Undeclared e) { lines.Add($"Undeclared={e.Capability}"); } + lines.Add("END_HOTPLUG_DRIFT"); + + lines.Add("FACET"); + var demo = new Facet( + (uint)ComposeRenderClassid(ClassIds["commercial_document"], 0x0002), + new byte[] { 1, 2, 3, 4, 5, 6 }, + new byte[] { 0x11, 0x12, 0x13, 0x14, 0x15, 0x16 }); + var encoded = demo.ToBytes(); + var decoded = Facet.FromBytes(encoded); + if (decoded.ClassId != demo.ClassId + || !decoded.IsAChain.SequenceEqual(demo.IsAChain) + || !decoded.PartOfChain.SequenceEqual(demo.PartOfChain)) + throw new Exception("facet round-trip must be lossless"); + lines.Add($"CLASSID={decoded.ClassId:X8}"); + lines.Add("IS_A=" + string.Join(",", decoded.IsAChain.Select(b => $"{b:X2}"))); + lines.Add("PART_OF=" + string.Join(",", decoded.PartOfChain.Select(b => $"{b:X2}"))); + lines.Add("END_FACET"); + + return string.Join("\n", lines) + "\n"; + } +} +"##; diff --git a/crates/ogar-adapter-csharp/tests/parity.rs b/crates/ogar-adapter-csharp/tests/parity.rs new file mode 100644 index 0000000..ab9d42c --- /dev/null +++ b/crates/ogar-adapter-csharp/tests/parity.rs @@ -0,0 +1,174 @@ +//! C# compile+run parity loop. +//! +//! Scaffolds a minimal `net8.0` console project under `/tmp` (NO +//! `PackageReference` — pure BCL, so no NuGet network is ever touched), +//! drops in the emitted class library plus a tiny `Program.cs`, runs +//! `dotnet build -v q` then `dotnet run`, and compares stdout against the +//! live `ogar_vocab` ground truth via `ogar_adapter_python::ground_truth` +//! (reused here as a `[dev-dependencies]` path dependency — the SAME +//! comparison function that verifies the Python emission). A second test +//! proves the facet decoder specifically: Rust builds a known 16-byte +//! facet by hand, C# decodes those exact bytes via +//! `BinaryPrimitives`/`Span`, and the fields must match. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +const NAMESPACE: &str = "Ogar.CapabilitySurface"; + +const CSPROJ: &str = r#" + + Exe + net8.0 + enable + disable + true + + +"#; + +/// A unique temp dir under `/tmp` (or `$TMPDIR`) for one test run. +fn unique_tmp_dir(tag: &str) -> PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or_default(); + let dir = std::env::temp_dir().join(format!( + "ogar-adapter-csharp-{tag}-{}-{nanos}", + std::process::id() + )); + fs::create_dir_all(&dir).expect("create temp dir under /tmp"); + dir +} + +fn dotnet() -> Command { + let mut cmd = Command::new("dotnet"); + cmd.env("DOTNET_CLI_TELEMETRY_OPTOUT", "1") + .env("DOTNET_SKIP_FIRST_TIME_EXPERIENCE", "1") + .env("DOTNET_NOLOGO", "1"); + cmd +} + +/// Write the csproj + the emitted class library into `dir`. The caller +/// supplies `program_cs` (the tiny entry point) since the two tests need +/// different `Main` bodies. +fn scaffold_project(dir: &Path, program_cs: &str) { + fs::write(dir.join("parity.csproj"), CSPROJ).expect("write csproj"); + fs::write( + dir.join("OgarCapabilitySurface.cs"), + ogar_adapter_csharp::emit_csharp(NAMESPACE), + ) + .expect("write emitted C# class library"); + fs::write(dir.join("Program.cs"), program_cs).expect("write Program.cs"); +} + +fn dotnet_build(dir: &Path) { + let build = dotnet() + .current_dir(dir) + .args(["build", "-v", "q"]) + .output() + .expect("spawn dotnet build"); + assert!( + build.status.success(), + "dotnet build failed (exit {:?}):\nstdout: {}\nstderr: {}", + build.status.code(), + String::from_utf8_lossy(&build.stdout), + String::from_utf8_lossy(&build.stderr), + ); +} + +fn dotnet_run(dir: &Path) -> String { + let run = dotnet() + .current_dir(dir) + .args(["run", "-v", "q", "--no-build"]) + .output() + .expect("spawn dotnet run"); + assert!( + run.status.success(), + "dotnet run failed (exit {:?}):\nstdout: {}\nstderr: {}", + run.status.code(), + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr), + ); + String::from_utf8_lossy(&run.stdout).into_owned() +} + +#[test] +fn emitted_library_builds_and_dump_matches_ground_truth() { + let dir = unique_tmp_dir("build-dump"); + scaffold_project( + &dir, + &format!("using {NAMESPACE};\nConsole.Write(OgarCapabilitySurface.Dump());\n"), + ); + + dotnet_build(&dir); + let dump = dotnet_run(&dir); + + // Compare the dump against the Rust-side ogar_vocab ground truth + // exactly like the Python loop — the SAME comparison function + // (ogar_adapter_python::ground_truth::assert_dump_matches), so the + // two verification loops can never silently diverge on what + // "correct" means. + ogar_adapter_python::ground_truth::assert_dump_matches("csharp", &dump); + + let _ = fs::remove_dir_all(&dir); +} + +#[test] +fn facet_bytes_built_in_rust_decode_correctly_in_csharp() { + let dir = unique_tmp_dir("facet"); + + // Rust builds a known 16-byte facet BY HAND, straight from the + // documented layout (facet.rs / ruff_spo_address::Facet doc): + // byte[0..4) = classid (u32 LE) + // byte[4 + 2t] = is_a_chain[t] (lo) + // byte[5 + 2t] = part_of_chain[t] (hi) + // Independent of ogar-adapter-csharp's own `Facet` type — proves C# + // decodes bytes Rust produced, not just bytes C# produced itself. + let classid = + ogar_vocab::app::render_classid(0x0002, ogar_vocab::class_ids::COMMERCIAL_DOCUMENT); + let is_a: [u8; 6] = [0x21, 0x22, 0x23, 0x24, 0x25, 0x26]; + let part_of: [u8; 6] = [0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6]; + let mut bytes = [0u8; 16]; + bytes[0..4].copy_from_slice(&classid.to_le_bytes()); + for t in 0..6 { + bytes[4 + 2 * t] = is_a[t]; + bytes[5 + 2 * t] = part_of[t]; + } + let byte_literals: Vec = bytes.iter().map(|b| format!("0x{b:02X}")).collect(); + + let program_cs = format!( + "using {NAMESPACE};\n\ + byte[] data = new byte[] {{ {} }};\n\ + var f = Facet.FromBytes(data);\n\ + Console.WriteLine($\"{{f.ClassId:X8}}\");\n\ + Console.WriteLine(string.Join(\",\", f.IsAChain.Select(b => $\"{{b:X2}}\")));\n\ + Console.WriteLine(string.Join(\",\", f.PartOfChain.Select(b => $\"{{b:X2}}\")));\n", + byte_literals.join(", "), + ); + scaffold_project(&dir, &program_cs); + + dotnet_build(&dir); + let got = dotnet_run(&dir); + + let expected = format!( + "{classid:08X}\n{}\n{}", + is_a.iter() + .map(|b| format!("{b:02X}")) + .collect::>() + .join(","), + part_of + .iter() + .map(|b| format!("{b:02X}")) + .collect::>() + .join(","), + ); + assert_eq!( + got.trim_end(), + expected.trim_end(), + "C#-decoded facet fields do not match the Rust-built bytes" + ); + + let _ = fs::remove_dir_all(&dir); +} diff --git a/crates/ogar-adapter-python/Cargo.toml b/crates/ogar-adapter-python/Cargo.toml new file mode 100644 index 0000000..b48d65e --- /dev/null +++ b/crates/ogar-adapter-python/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "ogar-adapter-python" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +rust-version.workspace = true +description = "Materializes the OGAR capability surface (class ids, domain action tables, hot-plug resolution, V3 facet decoder) as a self-contained generated Python module for foreign consumers -- the plug-and-play pattern without a Rust/OGAR dependency." + +[dependencies] +ogar-vocab = { path = "../ogar-vocab" } diff --git a/crates/ogar-adapter-python/examples/emit.rs b/crates/ogar-adapter-python/examples/emit.rs new file mode 100644 index 0000000..c69f376 --- /dev/null +++ b/crates/ogar-adapter-python/examples/emit.rs @@ -0,0 +1,31 @@ +//! Write the generated Python module to a file. +//! +//! Usage: +//! ```text +//! cargo run -p ogar-adapter-python --example emit -- [module_name] +//! ``` +//! `module_name` defaults to `ogar_capability_surface` (documentation-only — +//! see [`ogar_adapter_python::emit_python`]). + +use std::env; +use std::fs; +use std::process::ExitCode; + +fn main() -> ExitCode { + let mut args = env::args().skip(1); + let Some(output_path) = args.next() else { + eprintln!("usage: emit [module_name]"); + return ExitCode::from(2); + }; + let module_name = args + .next() + .unwrap_or_else(|| "ogar_capability_surface".to_string()); + + let content = ogar_adapter_python::emit_python(&module_name); + if let Err(e) = fs::write(&output_path, content) { + eprintln!("failed to write {output_path}: {e}"); + return ExitCode::FAILURE; + } + eprintln!("wrote {output_path} (module {module_name})"); + ExitCode::SUCCESS +} diff --git a/crates/ogar-adapter-python/src/lib.rs b/crates/ogar-adapter-python/src/lib.rs new file mode 100644 index 0000000..e160066 --- /dev/null +++ b/crates/ogar-adapter-python/src/lib.rs @@ -0,0 +1,730 @@ +//! `ogar-adapter-python` — materializes the OGAR capability surface as a +//! self-contained generated Python module for foreign consumers. +//! +//! Companion to `ogar-adapter-csharp`; together they give a non-Rust +//! consumer the same "hot-plug" plug-and-play pattern documented in +//! `.claude/knowledge/hotplug-consumer-migration.md` — without linking +//! against `ogar-vocab` at all. [`emit_python`] renders ONE Python file +//! (compile-time artifact, zero runtime serialization) carrying: +//! +//! 1. **`CLASS_IDS`** — every [`ogar_vocab::class_ids::ALL`] entry, plus +//! `compose_render_classid` / `concept_of` / `app_of` (the canon-high +//! classid composition helpers mirroring [`ogar_vocab::app`]). +//! 2. **Domain action tables** — every +//! [`ogar_vocab::capability_registry::domain_tables`] entry +//! generically (so a future domain lands on regeneration with zero +//! code change here), plus the FULL OCR specs from +//! [`ogar_vocab::ocr_actions::ocr_actions`] (capability, subject +//! concept + classid, typed params with mandatory flags, produces). +//! 3. **`HotPlug` + `resolve_hotplug`** — a pure Python mirror of +//! [`ogar_vocab::capability_registry::resolve_hotplug`], same five +//! drift arms (`UnknownClassid` / `NoCapabilitiesFor` / +//! `UnexpectedConsumer` / `Uncovered` / `Undeclared`), same check +//! order. +//! 4. **`Facet`** — the V3 4+12 content-blind facet read side: 16 bytes +//! -> `(classid u32 LE, six (lo, hi) byte pairs)`. See OGAR +//! `CLAUDE.md` P0 "THE CANONICAL GUID" / `E-V3-FACET-4-PLUS-12`, and +//! `ruff_spo_address::Facet` (the producer-side Rust twin this +//! mirrors byte-for-byte: `facet_classid(4 LE) | 6 x (lo:hi)`). +//! +//! Output is deterministic: the same `ogar-vocab` state always renders +//! the same bytes (stable ordering throughout the emitted tables). See +//! [`ground_truth`] for the canonical verification-dump format used to +//! prove parity against the live `ogar-vocab` data (shared with +//! `ogar-adapter-csharp`'s own verification loop via a `[dev-dependencies]` +//! path dependency on this crate — ONE comparison function, not two). + +#![forbid(unsafe_code)] +#![warn(missing_docs)] + +use ogar_vocab::capability_registry::domain_tables; +use ogar_vocab::class_ids; +use ogar_vocab::ocr_actions::ocr_actions; + +// ───────────────────────────────────────────────────────────────────── +// Public API +// ───────────────────────────────────────────────────────────────────── + +/// Render the self-contained generated Python module. +/// +/// `module_name` is documentation-only (embedded in the header +/// docstring) — Python module identity comes from the file's name / +/// placement on `sys.path`, not from any content inside the file. +/// +/// # Determinism +/// +/// Calling this twice against the same `ogar-vocab` state yields +/// byte-identical output — every table is emitted in the source data's +/// own stable order ([`ogar_vocab::class_ids::ALL`]'s declaration +/// order, [`ocr_actions`]'s table order, [`domain_tables`]'s +/// registration order), with no hashmap-iteration nondeterminism. +#[must_use] +pub fn emit_python(module_name: &str) -> String { + let mut out = PY_TEMPLATE.replace("@@MODULE_NAME@@", module_name); + out = out.replace("@@CLASS_IDS@@", &py_class_ids_block()); + out = out.replace("@@OCR_ACTIONS@@", &py_ocr_actions_block()); + out = out.replace( + "@@OCR_EXPECTED_EXECUTORS@@", + &py_str_tuple(ogar_vocab::ocr_actions::OCR_EXPECTED_EXECUTORS), + ); + out = out.replace( + "@@OCR_SUBJECT_CLASSIDS@@", + &py_hex_tuple(ogar_vocab::ocr_actions::OCR_SUBJECT_CLASSIDS), + ); + out = out.replace("@@DOMAIN_TABLES@@", &py_domain_tables_block()); + out +} + +// ───────────────────────────────────────────────────────────────────── +// Dynamic block builders — each pulls straight from live `ogar_vocab` +// data, never a hand-transcribed copy. +// ───────────────────────────────────────────────────────────────────── + +fn py_class_ids_block() -> String { + let mut s = String::new(); + for &(name, id) in class_ids::ALL { + s.push_str(&format!(" {name:?}: 0x{id:04X},\n")); + } + s +} + +fn py_ocr_actions_block() -> String { + let mut s = String::new(); + for spec in ocr_actions() { + let subject_concept_name = spec + .def + .object_class + .rsplit('/') + .next() + .unwrap_or_default() + .to_string(); + let subject_classid = + ogar_vocab::canonical_concept_id(&subject_concept_name).unwrap_or_default(); + let params: Vec = spec + .params + .iter() + .map(|p| format!("ActionParam({:?}, {})", p.name, py_bool(p.mandatory))) + .collect(); + let produces: Vec = spec.produces.iter().map(|p| format!("{p:?}")).collect(); + s.push_str(&format!( + " OcrActionSpec(\n capability={:?},\n subject_concept={subject_concept_name:?},\n subject_classid=0x{subject_classid:04X},\n params={},\n produces={},\n ),\n", + spec.def.predicate, + py_tuple(¶ms), + py_tuple(&produces), + )); + } + s +} + +fn py_domain_tables_block() -> String { + let mut s = String::new(); + for table in domain_tables() { + let execs: Vec = table + .expected_executors + .iter() + .map(|e| format!("{e:?}")) + .collect(); + let entries: Vec = (table.entries)() + .iter() + .map(|(cap, id)| format!("({cap:?}, 0x{id:04X})")) + .collect(); + s.push_str(&format!( + " DomainTable(\n domain={:?},\n expected_executors={},\n entries={},\n ),\n", + table.domain, + py_tuple(&execs), + py_tuple(&entries), + )); + } + s +} + +/// Build a Python tuple literal from already-formatted element strings. +/// `()` for zero elements; `(x,)` / `(x, y,)` otherwise — the trailing +/// comma is always syntactically valid in Python and sidesteps the +/// single-element-tuple gotcha (`("x")` is a plain string, not a tuple). +fn py_tuple(elems: &[String]) -> String { + if elems.is_empty() { + "()".to_string() + } else { + format!("({},)", elems.join(", ")) + } +} + +fn py_str_tuple(items: &[&str]) -> String { + let elems: Vec = items.iter().map(|s| format!("{s:?}")).collect(); + py_tuple(&elems) +} + +fn py_hex_tuple(items: &[u16]) -> String { + let elems: Vec = items.iter().map(|id| format!("0x{id:04X}")).collect(); + py_tuple(&elems) +} + +fn py_bool(b: bool) -> &'static str { + if b { "True" } else { "False" } +} + +// ───────────────────────────────────────────────────────────────────── +// The static Python template. `@@…@@` markers are the data seams filled +// in by `emit_python` above. +// ───────────────────────────────────────────────────────────────────── + +const PY_TEMPLATE: &str = r##""""GENERATED by ogar-adapter-python -- do not edit; regenerate from ogar-vocab. + +Module: @@MODULE_NAME@@ + +Materializes the OGAR capability surface for a foreign Python consumer: +canonical class ids, the domain action tables (OCR capability specs +today; any future `domain_tables()` entry lands here automatically on +regeneration), hot-plug resolution (`resolve_hotplug` mirror, same five +drift arms), and the V3 4+12 facet decoder (classid(4 LE) + 12-byte +payload as six (lo, hi) rails). + +Canon: OGAR CLAUDE.md P0 "THE CANONICAL GUID" + E-V3-FACET-4-PLUS-12 +(classid: u32 = [hi u16 CANON concept][lo u16 APP render prefix]). +Pattern: .claude/knowledge/hotplug-consumer-migration.md. +""" + +from __future__ import annotations + +import struct +from dataclasses import dataclass + + +# ============================================================ +# 1. CLASS_IDS -- canonical concept name -> u16 codebook id +# ============================================================ + +CLASS_IDS: dict[str, int] = { +@@CLASS_IDS@@} + +_ID_TO_NAME: dict[int, str] = {v: k for k, v in CLASS_IDS.items()} + + +def compose_render_classid(concept: int, app: int) -> int: + """Compose a full render classid: ``(concept << 16) | app``. + + Canon (OGAR CLAUDE.md P0 "THE CANONICAL GUID", operator-locked + 2026-07-02): ``classid: u32 = [hi u16 CANON concept][lo u16 APP / + render prefix]``. Mirrors ``ogar_vocab::app::render_classid`` + (parameter order flipped to concept-first per this module's contract). + """ + return ((concept & 0xFFFF) << 16) | (app & 0xFFFF) + + +def concept_of(classid: int) -> int: + """The CANON concept half (high u16) of a full classid.""" + return (classid >> 16) & 0xFFFF + + +def app_of(classid: int) -> int: + """The APP / render-prefix half (low u16) of a full classid.""" + return classid & 0xFFFF + + +# ============================================================ +# 2. Domain action tables +# ============================================================ + + +@dataclass(frozen=True) +class ActionParam: + """One parameter of a capability's typed I/O signature.""" + + name: str + mandatory: bool + + +@dataclass(frozen=True) +class OcrActionSpec: + """One OCR capability -- mirrors `ogar_vocab::ocr_actions::OcrActionSpec`.""" + + capability: str + subject_concept: str + subject_classid: int + params: tuple[ActionParam, ...] + produces: tuple[str, ...] + + +OCR_ACTIONS: tuple[OcrActionSpec, ...] = ( +@@OCR_ACTIONS@@) + +OCR_EXPECTED_EXECUTORS: tuple[str, ...] = @@OCR_EXPECTED_EXECUTORS@@ + +OCR_SUBJECT_CLASSIDS: tuple[int, ...] = @@OCR_SUBJECT_CLASSIDS@@ + +# Every OCR capability name, in table order -- matches +# `ogar_vocab::ocr_actions::OCR_ACTION_NAMES`. Used below as the +# "everything covered" scenario for the hot-plug verification dump. +OCR_EXPECTED_COVERED_ALL: tuple[str, ...] = tuple(a.capability for a in OCR_ACTIONS) + + +@dataclass(frozen=True) +class DomainTable: + """One authoritative domain action table -- the generic hot-plug join + surface. Mirrors `ogar_vocab::capability_registry::DomainTable`. + """ + + domain: str + expected_executors: tuple[str, ...] + entries: tuple[tuple[str, int], ...] + + +DOMAIN_TABLES: tuple[DomainTable, ...] = ( +@@DOMAIN_TABLES@@) + + +# ============================================================ +# 3. HotPlug + resolve_hotplug +# ============================================================ + + +@dataclass(frozen=True) +class HotPlug: + """A consumer's hot-plug declaration. Mirrors + `lance_graph_contract::hotplug::HotPlug`. + """ + + consumer: str + classids: tuple[int, ...] + covered: tuple[str, ...] + + +class HotplugDrift(Exception): + """Base class for every `resolve_hotplug` failure arm.""" + + +class UnknownClassid(HotplugDrift): + """A hot-plugged classid is not minted in the codebook.""" + + def __init__(self, classid: int) -> None: + self.classid = classid + super().__init__(f"hot-plugged classid 0x{classid:04X} is not minted") + + +class NoCapabilitiesFor(HotplugDrift): + """A hot-plugged classid resolves to no declared capability.""" + + def __init__(self, classid: int) -> None: + self.classid = classid + super().__init__( + f"classid 0x{classid:04X} resolves to no declared capability" + ) + + +class UnexpectedConsumer(HotplugDrift): + """The consumer is not an expected executor for a contributing table.""" + + def __init__(self, consumer: str) -> None: + self.consumer = consumer + super().__init__(f"consumer `{consumer}` is not an expected executor") + + +class Uncovered(HotplugDrift): + """A declared capability has no consumer arm.""" + + def __init__(self, capability: str) -> None: + self.capability = capability + super().__init__(f"declared capability `{capability}` has no consumer arm") + + +class Undeclared(HotplugDrift): + """The consumer covers a capability the authority does not declare.""" + + def __init__(self, capability: str) -> None: + self.capability = capability + super().__init__(f"consumer covers `{capability}` which is not declared") + + +def resolve_hotplug( + consumer: str, + classids: list[int] | tuple[int, ...], + covered: list[str] | tuple[str, ...], +) -> tuple[list[tuple[str, int]], list[str]]: + """Pure Python mirror of + `ogar_vocab::capability_registry::resolve_hotplug`. + + Checks, in order: every classid minted (`UnknownClassid`); per + contributing domain table, the consumer is an expected executor + (`UnexpectedConsumer`); every hot-plugged classid contributes at + least one capability (`NoCapabilitiesFor`); coverage both directions + (`Uncovered` / `Undeclared`). + """ + classids = list(classids) + covered = list(covered) + + concepts: list[tuple[str, int]] = [] + for cid in classids: + name = _ID_TO_NAME.get(cid) + if name is None: + raise UnknownClassid(cid) + concepts.append((name, cid)) + + # BTreeMap-equivalent: distinct classids, ascending, count of + # contributing (capability, subject) rows seen so far. + unique_sorted = sorted(set(classids)) + contributed: dict[int, int] = {cid: 0 for cid in unique_sorted} + capabilities: list[str] = [] + + for table in DOMAIN_TABLES: + table_contributes = False + for cap, subject in table.entries: + if subject in contributed: + contributed[subject] += 1 + table_contributes = True + capabilities.append(cap) + if table_contributes and consumer not in table.expected_executors: + raise UnexpectedConsumer(consumer) + + for cid in unique_sorted: + if contributed[cid] == 0: + raise NoCapabilitiesFor(cid) + + capabilities = sorted(set(capabilities)) + + covered_set = set(covered) + for cap in capabilities: + if cap not in covered_set: + raise Uncovered(cap) + + capabilities_set = set(capabilities) + for cap in covered: + if cap not in capabilities_set: + raise Undeclared(cap) + + return concepts, capabilities + + +# ============================================================ +# 4. Facet -- the V3 4+12 content-blind facet, read side +# ============================================================ + +TIERS = 6 + + +@dataclass(frozen=True) +class Facet: + """The V3 4+12 content-blind facet: ``classid(4 LE) + 6x(lo:hi)`` + rails, byte-identical to ``ruff_spo_address::Facet`` / + ``lance_graph_contract::facet::FacetCascade``. + + Canon: OGAR CLAUDE.md ``E-V3-FACET-4-PLUS-12`` -- the 12-byte payload + is a content-blind byte register the classid's ClassView projects; + here it is read back as six ``(lo, hi)`` byte pairs (``u8:u8``, + NEVER widened to u16/u24). + """ + + classid: int + is_a_chain: tuple[int, ...] + part_of_chain: tuple[int, ...] + + @staticmethod + def from_bytes(data: bytes) -> "Facet": + """Decode 16 bytes -> ``(classid u32 LE, six (lo, hi) pairs)``.""" + if len(data) != 16: + raise ValueError(f"facet must be exactly 16 bytes, got {len(data)}") + (classid,) = struct.unpack_from(" bytes: + """Encode back to the 16-byte wire form (round-trip inverse).""" + out = bytearray(16) + struct.pack_into(" str: + """Render the canonical verification dump (see module doc).""" + lines: list[str] = [] + + lines.append("CLASS_IDS") + for name, cid in sorted(CLASS_IDS.items(), key=lambda kv: kv[0]): + lines.append(f"{name}={cid:04X}") + lines.append("END_CLASS_IDS") + + lines.append("OCR_ACTIONS") + for a in OCR_ACTIONS: + params = ",".join(f"{p.name}:{1 if p.mandatory else 0}" for p in a.params) + produces = ",".join(a.produces) + lines.append( + f"{a.capability}|{a.subject_concept}|{a.subject_classid:04X}|{params}|{produces}" + ) + lines.append("END_OCR_ACTIONS") + + lines.append("DOMAIN_TABLES") + for t in DOMAIN_TABLES: + execs = ",".join(t.expected_executors) + entries = ",".join(f"{cap}:{cid:04X}" for cap, cid in t.entries) + lines.append(f"{t.domain}|{execs}|{entries}") + lines.append("END_DOMAIN_TABLES") + + lines.append("HOTPLUG_GREEN") + concepts, capabilities = resolve_hotplug( + "tesseract-ogar", OCR_SUBJECT_CLASSIDS, OCR_EXPECTED_COVERED_ALL + ) + concepts_str = ",".join(f"{n}:{cid:04X}" for n, cid in concepts) + lines.append(f"CONCEPTS={concepts_str}") + lines.append(f"CAPABILITIES={','.join(capabilities)}") + lines.append("END_HOTPLUG_GREEN") + + lines.append("HOTPLUG_DRIFT") + try: + resolve_hotplug("tesseract-ogar", [0xFFFE], []) + raise AssertionError("expected UnknownClassid") + except UnknownClassid as e: + lines.append(f"UnknownClassid={e.classid:04X}") + + try: + resolve_hotplug("tesseract-ogar", [CLASS_IDS["patient"]], []) + raise AssertionError("expected NoCapabilitiesFor") + except NoCapabilitiesFor as e: + lines.append(f"NoCapabilitiesFor={e.classid:04X}") + + try: + resolve_hotplug("stranger", OCR_SUBJECT_CLASSIDS, OCR_EXPECTED_COVERED_ALL) + raise AssertionError("expected UnexpectedConsumer") + except UnexpectedConsumer as e: + lines.append(f"UnexpectedConsumer={e.consumer}") + + missing = list(OCR_EXPECTED_COVERED_ALL) + missing.pop() + try: + resolve_hotplug("tesseract-ogar", OCR_SUBJECT_CLASSIDS, missing) + raise AssertionError("expected Uncovered") + except Uncovered as e: + lines.append(f"Uncovered={e.capability}") + + extra = list(OCR_EXPECTED_COVERED_ALL) + ["does_not_exist"] + try: + resolve_hotplug("tesseract-ogar", OCR_SUBJECT_CLASSIDS, extra) + raise AssertionError("expected Undeclared") + except Undeclared as e: + lines.append(f"Undeclared={e.capability}") + lines.append("END_HOTPLUG_DRIFT") + + lines.append("FACET") + demo = Facet( + classid=compose_render_classid(CLASS_IDS["commercial_document"], 0x0002), + is_a_chain=(1, 2, 3, 4, 5, 6), + part_of_chain=(0x11, 0x12, 0x13, 0x14, 0x15, 0x16), + ) + encoded = demo.to_bytes() + decoded = Facet.from_bytes(encoded) + assert decoded == demo, "facet round-trip must be lossless" + lines.append(f"CLASSID={decoded.classid:08X}") + lines.append("IS_A=" + ",".join(f"{b:02X}" for b in decoded.is_a_chain)) + lines.append("PART_OF=" + ",".join(f"{b:02X}" for b in decoded.part_of_chain)) + lines.append("END_FACET") + + return "\n".join(lines) + "\n" + + +if __name__ == "__main__": + print(dump()) +"##; + +// ───────────────────────────────────────────────────────────────────── +// Ground truth — shared by BOTH this crate's and `ogar-adapter-csharp`'s +// integration tests. Not `#[cfg(test)]`-gated: `ogar-adapter-csharp` +// links this crate as a plain `[dev-dependencies]` path dependency +// purely to reuse [`ground_truth::expected_dump`] and +// [`ground_truth::assert_dump_matches`] — ONE comparison function, not +// two independently-maintained ones. +// ───────────────────────────────────────────────────────────────────── + +/// Ground-truth generation + comparison for the verification dump both +/// the emitted Python `dump()` and the emitted C# `Dump()` must +/// reproduce byte-for-byte. +pub mod ground_truth { + use ogar_vocab::capability_registry::{HotplugDrift, domain_tables, resolve_hotplug}; + use ogar_vocab::class_ids; + use ogar_vocab::ocr_actions::{self, ocr_actions}; + + type HotplugOk = (Vec<(&'static str, u16)>, Vec); + + /// The canonical text-dump format both the emitted Python `dump()` + /// and the emitted C# `Dump()` must reproduce byte-for-byte. Built + /// directly from live `ogar_vocab` calls — never hand-transcribed — + /// so drift between the authority and either foreign emission is + /// caught here, not silently accepted. + #[must_use] + pub fn expected_dump() -> String { + let mut out = String::new(); + + out.push_str("CLASS_IDS\n"); + let mut sorted: Vec<(&str, u16)> = class_ids::ALL.to_vec(); + sorted.sort_by(|a, b| a.0.cmp(b.0)); + for (name, id) in &sorted { + out.push_str(&format!("{name}={id:04X}\n")); + } + out.push_str("END_CLASS_IDS\n"); + + out.push_str("OCR_ACTIONS\n"); + for spec in ocr_actions() { + let subject_concept = spec.def.object_class.rsplit('/').next().unwrap_or_default(); + let subject_classid = + ogar_vocab::canonical_concept_id(subject_concept).unwrap_or_default(); + let params = spec + .params + .iter() + .map(|p| format!("{}:{}", p.name, u8::from(p.mandatory))) + .collect::>() + .join(","); + let produces = spec.produces.join(","); + out.push_str(&format!( + "{}|{subject_concept}|{subject_classid:04X}|{params}|{produces}\n", + spec.def.predicate, + )); + } + out.push_str("END_OCR_ACTIONS\n"); + + out.push_str("DOMAIN_TABLES\n"); + for table in domain_tables() { + let execs = table.expected_executors.join(","); + let entries = (table.entries)() + .iter() + .map(|(c, id)| format!("{c}:{id:04X}")) + .collect::>() + .join(","); + out.push_str(&format!("{}|{execs}|{entries}\n", table.domain)); + } + out.push_str("END_DOMAIN_TABLES\n"); + + let ocr_ids = ocr_actions::OCR_SUBJECT_CLASSIDS; + let all_covered = ocr_actions::OCR_ACTION_NAMES; + + out.push_str("HOTPLUG_GREEN\n"); + let (concepts, capabilities) = resolve_hotplug("tesseract-ogar", ocr_ids, all_covered) + .expect("ground truth green resolve_hotplug must succeed"); + let concepts_str = concepts + .iter() + .map(|(n, id)| format!("{n}:{id:04X}")) + .collect::>() + .join(","); + out.push_str(&format!("CONCEPTS={concepts_str}\n")); + out.push_str(&format!("CAPABILITIES={}\n", capabilities.join(","))); + out.push_str("END_HOTPLUG_GREEN\n"); + + out.push_str("HOTPLUG_DRIFT\n"); + let r1 = resolve_hotplug("tesseract-ogar", &[0xFFFEu16], &[]); + out.push_str(&format!( + "UnknownClassid={}\n", + expect_drift_hex(r1, "UnknownClassid") + )); + let r2 = resolve_hotplug("tesseract-ogar", &[class_ids::PATIENT], &[]); + out.push_str(&format!( + "NoCapabilitiesFor={}\n", + expect_drift_hex(r2, "NoCapabilitiesFor") + )); + let r3 = resolve_hotplug("stranger", ocr_ids, all_covered); + out.push_str(&format!( + "UnexpectedConsumer={}\n", + expect_drift_str(r3, "UnexpectedConsumer") + )); + let mut missing = all_covered.to_vec(); + missing.pop(); + let r4 = resolve_hotplug("tesseract-ogar", ocr_ids, &missing); + out.push_str(&format!( + "Uncovered={}\n", + expect_drift_str(r4, "Uncovered") + )); + let mut extra = all_covered.to_vec(); + extra.push("does_not_exist"); + let r5 = resolve_hotplug("tesseract-ogar", ocr_ids, &extra); + out.push_str(&format!( + "Undeclared={}\n", + expect_drift_str(r5, "Undeclared") + )); + out.push_str("END_HOTPLUG_DRIFT\n"); + + let classid = ogar_vocab::app::render_classid(0x0002, class_ids::COMMERCIAL_DOCUMENT); + let is_a: [u8; 6] = [1, 2, 3, 4, 5, 6]; + let part_of: [u8; 6] = [0x11, 0x12, 0x13, 0x14, 0x15, 0x16]; + out.push_str("FACET\n"); + out.push_str(&format!("CLASSID={classid:08X}\n")); + out.push_str(&format!( + "IS_A={}\n", + is_a.iter() + .map(|b| format!("{b:02X}")) + .collect::>() + .join(",") + )); + out.push_str(&format!( + "PART_OF={}\n", + part_of + .iter() + .map(|b| format!("{b:02X}")) + .collect::>() + .join(",") + )); + out.push_str("END_FACET\n"); + + out + } + + fn expect_drift_hex(r: Result, want: &str) -> String { + match (&r, want) { + (Err(HotplugDrift::UnknownClassid(id)), "UnknownClassid") => format!("{id:04X}"), + (Err(HotplugDrift::NoCapabilitiesFor(id)), "NoCapabilitiesFor") => { + format!("{id:04X}") + } + _ => panic!("expected {want} drift, got {r:?}"), + } + } + + fn expect_drift_str(r: Result, want: &str) -> String { + match (&r, want) { + (Err(HotplugDrift::UnexpectedConsumer(c)), "UnexpectedConsumer") => c.clone(), + (Err(HotplugDrift::Uncovered(c)), "Uncovered") => c.clone(), + (Err(HotplugDrift::Undeclared(c)), "Undeclared") => c.clone(), + _ => panic!("expected {want} drift, got {r:?}"), + } + } + + /// Compare a foreign-emitted dump (Python stdout, C# stdout) against + /// the live `ogar-vocab` ground truth ([`expected_dump`]). Panics + /// with a line-level diff on the first mismatch. `source` names the + /// caller (`"python"` / `"csharp"`) for a readable panic message. + /// + /// Shared by both `ogar-adapter-python`'s and `ogar-adapter-csharp`'s + /// integration tests — the ONE comparison function referenced in + /// both verification loops. + pub fn assert_dump_matches(source: &str, actual: &str) { + let expected = expected_dump(); + // Trim trailing whitespace/newlines before splitting: subprocess + // stdout capture (and a caller using `Console.WriteLine` / + // `print()` instead of the newline-free `Write` / `end=""` form) + // routinely adds or drops exactly one trailing newline, which is + // not a real content mismatch. + let expected_lines: Vec<&str> = expected.trim_end().lines().collect(); + let actual_lines: Vec<&str> = actual.trim_end().lines().collect(); + let n = expected_lines.len().min(actual_lines.len()); + for i in 0..n { + assert_eq!( + expected_lines[i], + actual_lines[i], + "{source}: dump mismatch at line {}", + i + 1 + ); + } + assert_eq!( + expected_lines.len(), + actual_lines.len(), + "{source}: dump line count mismatch\n--- expected ---\n{expected}\n--- actual ---\n{actual}", + ); + } +} diff --git a/crates/ogar-adapter-python/tests/parity.rs b/crates/ogar-adapter-python/tests/parity.rs new file mode 100644 index 0000000..11d3c72 --- /dev/null +++ b/crates/ogar-adapter-python/tests/parity.rs @@ -0,0 +1,155 @@ +//! Python compile+run parity loop. +//! +//! Emits the generated module to a temp dir under `/tmp`, verifies it +//! compiles (`python3 -m py_compile`), then imports it via `python3 -c` +//! and compares its `dump()` output against the live `ogar_vocab` ground +//! truth ([`ogar_adapter_python::ground_truth`]). A second test proves +//! the facet decoder specifically: Rust builds a known 16-byte facet by +//! hand (per the documented byte layout, independent of the `Facet` +//! type), Python decodes those exact bytes, and the fields must match. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +const MODULE_NAME: &str = "ogar_capability_surface"; + +/// A unique temp dir under `/tmp` (or `$TMPDIR`) for one test run. +fn unique_tmp_dir(tag: &str) -> PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or_default(); + let dir = std::env::temp_dir().join(format!( + "ogar-adapter-python-{tag}-{}-{nanos}", + std::process::id() + )); + fs::create_dir_all(&dir).expect("create temp dir under /tmp"); + dir +} + +fn python3() -> Command { + Command::new("python3") +} + +fn write_module(dir: &Path) -> PathBuf { + let module_path = dir.join(format!("{MODULE_NAME}.py")); + fs::write(&module_path, ogar_adapter_python::emit_python(MODULE_NAME)) + .expect("write emitted module"); + module_path +} + +#[test] +fn emitted_module_py_compiles_and_dump_matches_ground_truth() { + let dir = unique_tmp_dir("compile-dump"); + let module_path = write_module(&dir); + + // `python3 -m py_compile ` — exit 0 required. + let compile = python3() + .args(["-m", "py_compile"]) + .arg(&module_path) + .output() + .expect("spawn python3 -m py_compile"); + assert!( + compile.status.success(), + "python3 -m py_compile failed (exit {:?}):\nstdout: {}\nstderr: {}", + compile.status.code(), + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr), + ); + + // `python3 -c` importing the module and dumping (a) all (name,id) + // pairs sorted, (b) capability names + subject ids, (c) the green + // resolve_hotplug result, (d) each of the five drift arms fired — + // all encoded in dump()'s single canonical text format. + let run = python3() + .env("PYTHONPATH", &dir) + .arg("-c") + .arg(format!( + "import {MODULE_NAME} as m; print(m.dump(), end='')" + )) + .output() + .expect("spawn python3 -c ... dump()"); + assert!( + run.status.success(), + "python3 -c dump() failed (exit {:?}):\nstdout: {}\nstderr: {}", + run.status.code(), + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr), + ); + let dump = String::from_utf8_lossy(&run.stdout).into_owned(); + + // Compare every dump line against the live ogar_vocab ground truth + // (string compare, both sides already produced in sorted/canonical + // order by construction). + ogar_adapter_python::ground_truth::assert_dump_matches("python", &dump); + + let _ = fs::remove_dir_all(&dir); +} + +#[test] +fn facet_bytes_built_in_rust_decode_correctly_in_python() { + let dir = unique_tmp_dir("facet"); + write_module(&dir); + + // Rust builds a known 16-byte facet BY HAND, straight from the + // documented layout (facet.rs / ruff_spo_address::Facet doc): + // byte[0..4) = classid (u32 LE) + // byte[4 + 2t] = is_a_chain[t] (lo) + // byte[5 + 2t] = part_of_chain[t] (hi) + // This is independent of ogar-adapter-python's own `Facet` type — + // it proves Python decodes bytes Rust produced, not just bytes + // Python produced itself. + let classid = + ogar_vocab::app::render_classid(0x0002, ogar_vocab::class_ids::COMMERCIAL_DOCUMENT); + let is_a: [u8; 6] = [0x21, 0x22, 0x23, 0x24, 0x25, 0x26]; + let part_of: [u8; 6] = [0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6]; + let mut bytes = [0u8; 16]; + bytes[0..4].copy_from_slice(&classid.to_le_bytes()); + for t in 0..6 { + bytes[4 + 2 * t] = is_a[t]; + bytes[5 + 2 * t] = part_of[t]; + } + let hex: String = bytes.iter().map(|b| format!("{b:02x}")).collect(); + + let script = format!( + "import {MODULE_NAME} as m\n\ + f = m.Facet.from_bytes(bytes.fromhex({hex:?}))\n\ + print(f'{{f.classid:08X}}')\n\ + print(','.join(f'{{b:02X}}' for b in f.is_a_chain))\n\ + print(','.join(f'{{b:02X}}' for b in f.part_of_chain))\n" + ); + let run = python3() + .env("PYTHONPATH", &dir) + .arg("-c") + .arg(&script) + .output() + .expect("spawn python3 -c ... facet decode"); + assert!( + run.status.success(), + "python3 facet decode failed (exit {:?}):\nstdout: {}\nstderr: {}", + run.status.code(), + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr), + ); + let got = String::from_utf8_lossy(&run.stdout).into_owned(); + let expected = format!( + "{classid:08X}\n{}\n{}", + is_a.iter() + .map(|b| format!("{b:02X}")) + .collect::>() + .join(","), + part_of + .iter() + .map(|b| format!("{b:02X}")) + .collect::>() + .join(","), + ); + assert_eq!( + got.trim_end(), + expected.trim_end(), + "python-decoded facet fields do not match the Rust-built bytes" + ); + + let _ = fs::remove_dir_all(&dir); +}