Skip to content

Commit 299e6c4

Browse files
committed
feat(lance-graph-ontology): PR-bO-13 SkrHydrator + DATEV SKR 03/04 slots
Third Tier-C hydrator. Reads the SKR 03 / SKR 04 CSVs that landed in the previous commit and interns each account as a stable u32 IRI in the OntologyRegistry. The two schemes hydrate into SEPARATE G slots (SKR03_V1=40, SKR04_V1=41) because the same account number means different things across them — e.g. account 1000 is "Kasse" in SKR 03 but "Roh-, Hilfs- und Betriebsstoffe" in SKR 04. New SkrHydrator (minimal name-extraction shape): - crates/lance-graph-ontology/src/hydrators/skr.rs — generic CSV-driven hydrator. Streams a CSV via a small hand-rolled parser (handles double-quoted fields with embedded commas + "" escape), interns `{iri_prefix}/{account_number}` for each row. - No new external dep — CSV parsing is ~30 lines of state-machine. - 2 unit tests cover the CSV parser and a tiny end-to-end hydration. New glue (hydrators/skr_datev.rs) ships three entry points: - hydrate_skr03 → OGIT::SKR03_V1, IRI base `urn:datev:skr03:account` - hydrate_skr04 → OGIT::SKR04_V1, IRI base `urn:datev:skr04:account` - hydrate_skr03_bau → also OGIT::SKR03_V1 but with a separate IRI base `urn:datev:skr03-bau:account` so 6-digit Bau accounts coexist with canonical 4-digit ones without clashing. CSV data regeneration (data/ontologies/skr-datev/parse_pdfs.py): - Widened SKR 03 Bau column slice from [44, 90) to [38, 100). The old bound cut off account numbers preceded by function-code prefixes ("F 1000 00 Kasse" started at col 41; slicing at 44 dropped the function code AND the first digit). SKR 03 canonical entity count grew 1476 -> 1623. - SKR 04: unchanged. Manifests + slots: - modules/skr03/manifest.yaml — declares anchor accounts (1000 Kasse, 1200 Bank, 1400 Forderungen LL, 1576 Vorsteuer 19%, 3300/8400/8300 revenues) with stable u16 entity IDs for external code reference. - modules/skr04/manifest.yaml — declares balance-sheet-oriented anchors (1000 RHB, 1200 Forderungen LL, 1400 Vorsteuer, 1600 Kasse, 1800 Bank, 2900 Eigenkapital, 3300 Verbindlichkeiten, 4400 Erlöse, 5400 Wareneingang) - crates/lance-graph-contract/build.rs: adds SKR03=40 + SKR04=41 to the G-slot table. 6 smoke tests verify: - both schemes hydrate into their respective G slots with the right domain name and DOLCE inheritance - entity counts in expected range (~1500 each) - load-bearing anchor accounts resolve under their scheme-specific IRIs (Kasse / Bank / Forderungen / Vorsteuer / Wareneingang / Erlöse) - SKR 03 / SKR 04 are independent slots — SKR03 IRIs don't resolve in the SKR04 bundle and vice versa - SKR 03 Bau extensions (007510 Sand- und Kiesausbeute, 010010 Bauliche Anlagen für stationäre Fertigung) resolve under the Bau IRI base Known parser coverage gaps (documented in test comments): - SKR 04 account 2900 "Gezeichnetes Kapital" not extracted (page-8 column boundary issue) - SKR 04 account 4400 entangled in multi-account bleed at 4332 Both are CSV data quality issues, not hydrator bugs. The hydrator is correct; future parse_pdfs.py refinement will close these gaps. All 114 lance-graph-ontology tests pass; clippy clean; downstream consumers (callcenter, consumer-conformance, cognitive-shader-driver) build clean.
1 parent 6f983db commit 299e6c4

11 files changed

Lines changed: 1681 additions & 761 deletions

File tree

crates/lance-graph-contract/build.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,15 @@ const CANONICAL_SLOTS: &[(&str, u32)] = &[
4646
// EN16931 / PEPPOL / CO / DE business-rule IDs from the message
4747
// bodies. Hydrated via SchematronHydrator.
4848
("ZUGFERDRULES", 31),
49+
// L3 German chart of accounts (PR-bO-13). DATEV SKR is the de-facto
50+
// canonical bookkeeping scheme for HGB-compliant German SMEs.
51+
// SKR 03 uses process-oriented family numbering; SKR 04 uses
52+
// balance-sheet-oriented. Each is hydrated as a separate G slot
53+
// because account numbers DO NOT mean the same thing across the two
54+
// schemes (e.g. account 1000 is "Roh-, Hilfs- und Betriebsstoffe"
55+
// in SKR 04 but "Kasse" in SKR 03).
56+
("SKR03", 40),
57+
("SKR04", 41),
4958
];
5059

5160
fn canonical_slot(token: &str) -> Option<u32> {

crates/lance-graph-ontology/src/hydrators/mod.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ pub mod qudt;
2727
pub mod schemaorg;
2828
pub mod schematron;
2929
pub mod skos;
30+
pub mod skr;
31+
pub mod skr_datev;
3032
pub mod xsd;
3133
pub mod zugferd;
3234

@@ -39,6 +41,11 @@ pub use qudt::{hydrate_qudt, hydrate_qudt_from};
3941
pub use schemaorg::{hydrate_schemaorg, hydrate_schemaorg_from};
4042
pub use schematron::SchematronHydrator;
4143
pub use skos::{hydrate_skos, hydrate_skos_from};
44+
pub use skr::SkrHydrator;
45+
pub use skr_datev::{
46+
hydrate_skr03, hydrate_skr03_bau, hydrate_skr03_bau_from, hydrate_skr03_from, hydrate_skr04,
47+
hydrate_skr04_from, SKR03_BAU_IRI_PREFIX, SKR03_IRI_PREFIX, SKR04_IRI_PREFIX,
48+
};
4249
pub use xsd::{collect_xsd_files, XsdHydrator};
4350
pub use zugferd::{
4451
hydrate_zugferd, hydrate_zugferd_from, hydrate_zugferd_rules, hydrate_zugferd_rules_from,
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
//! DATEV SKR (Standardkontenrahmen) -> `ContextBundle` hydrator.
2+
//!
3+
//! The Standardkontenrahmen is the German de-facto chart of accounts used
4+
//! for HGB / EStG-compliant bookkeeping. Two parallel schemes coexist:
5+
//!
6+
//! - **SKR 03** (Prozessgliederungsprinzip) — process-oriented family
7+
//! numbering; used by most German SMEs.
8+
//! - **SKR 04** (Abschlussgliederungsprinzip) — balance-sheet-oriented
9+
//! numbering aligned with HGB §266 P&L structure; used by larger firms.
10+
//!
11+
//! The two schemes are NOT interchangeable: account `1000` means
12+
//! "Roh-, Hilfs- und Betriebsstoffe" in SKR 04 but "Kasse" in SKR 03.
13+
//! They therefore hydrate into separate G slots
14+
//! (`OGIT::SKR03_V1` and `OGIT::SKR04_V1`).
15+
//!
16+
//! Pattern D, minimal-name-extraction shape:
17+
//!
18+
//! Each row in the source CSV (`data/ontologies/skr-datev/skr0{3,4}.csv`)
19+
//! contributes ONE IRI: `urn:datev:skr0{3,4}:account/{number}`. Family
20+
//! classifications (Anlagevermögen, Erlöse, etc.) are NOT projected as
21+
//! separate IRIs in this minimal hydrator — they live in the CSV's
22+
//! `family` column and can be lifted to a SKOS Collection axiom in a
23+
//! follow-up PR.
24+
//!
25+
//! CSV format:
26+
//!
27+
//! ```csv
28+
//! account_number,account_name,family,source
29+
//! 0050,"Ausstehende Einlagen auf das ...",Anlagevermögen,"DATEV SKR 04 ..."
30+
//! ```
31+
//!
32+
//! Quoted fields (containing commas / quotes) are supported; the CSV is
33+
//! parsed by a small streaming state machine to avoid pulling a new dep.
34+
35+
use std::collections::HashMap;
36+
use std::fs;
37+
use std::path::Path;
38+
use std::sync::Arc;
39+
40+
use super::owl::{ContextBundle, EntityId, HydrateErr, OntologySlot};
41+
use crate::registry::OntologyRegistry;
42+
43+
/// Hydrator for a DATEV SKR CSV. One instance hydrates one `ContextBundle`
44+
/// keyed by `g`, drawing rows from a single CSV file. `iri_prefix` is the
45+
/// URN base each row's `account_number` is appended to.
46+
pub struct SkrHydrator {
47+
pub g: u32,
48+
pub version: u32,
49+
pub domain_name: String,
50+
pub inherits_from: Option<u32>,
51+
pub starting_entity_id: EntityId,
52+
/// e.g. `"urn:datev:skr04:account"` — the `/{account_number}` is appended.
53+
pub iri_prefix: String,
54+
}
55+
56+
impl SkrHydrator {
57+
/// Read the CSV at `csv_path` and intern each row's account number as
58+
/// `{iri_prefix}/{account_number}`.
59+
pub fn hydrate(
60+
&self,
61+
csv_path: &Path,
62+
registry: &OntologyRegistry,
63+
) -> Result<u32, HydrateErr> {
64+
let bytes = fs::read(csv_path).map_err(|e| HydrateErr::Io {
65+
path: csv_path.to_path_buf(),
66+
source: e,
67+
})?;
68+
let text = std::str::from_utf8(&bytes).map_err(|e| HydrateErr::Parse {
69+
path: csv_path.to_path_buf(),
70+
message: format!("CSV not UTF-8: {e}"),
71+
})?;
72+
73+
let mut iri_to_id: HashMap<String, EntityId> = HashMap::new();
74+
let mut next_id: EntityId = self.starting_entity_id;
75+
76+
let mut header_seen = false;
77+
for line in text.lines() {
78+
if line.is_empty() {
79+
continue;
80+
}
81+
if !header_seen {
82+
header_seen = true;
83+
continue;
84+
}
85+
let fields = parse_csv_line(line);
86+
if fields.is_empty() {
87+
continue;
88+
}
89+
let account_number = fields[0].trim();
90+
if account_number.is_empty() {
91+
continue;
92+
}
93+
let iri = format!("{}/{}", self.iri_prefix, account_number);
94+
if !iri_to_id.contains_key(&iri) {
95+
let id = next_id;
96+
next_id += 1;
97+
iri_to_id.insert(iri, id);
98+
}
99+
}
100+
101+
let entity_count = iri_to_id.len() as u32;
102+
let ontology = OntologySlot {
103+
entity_count,
104+
iri_to_id,
105+
};
106+
let bundle = ContextBundle {
107+
g: self.g,
108+
version: self.version,
109+
domain_name: self.domain_name.clone(),
110+
inherits_from: self.inherits_from,
111+
ontology: Some(Arc::new(ontology)),
112+
edge_types: Vec::new(),
113+
};
114+
registry.register_bundle(bundle);
115+
Ok(self.g)
116+
}
117+
}
118+
119+
/// Minimal CSV-line parser. Handles double-quoted fields with embedded commas
120+
/// and the `""` escape for a literal quote inside a quoted field. Does NOT
121+
/// handle multi-line records (those don't occur in DATEV SKR CSVs).
122+
fn parse_csv_line(line: &str) -> Vec<String> {
123+
let mut out: Vec<String> = Vec::new();
124+
let mut cur = String::new();
125+
let mut in_quotes = false;
126+
let mut chars = line.chars().peekable();
127+
while let Some(c) = chars.next() {
128+
if in_quotes {
129+
if c == '"' {
130+
// `""` -> literal `"`.
131+
if chars.peek() == Some(&'"') {
132+
chars.next();
133+
cur.push('"');
134+
} else {
135+
in_quotes = false;
136+
}
137+
} else {
138+
cur.push(c);
139+
}
140+
} else if c == '"' {
141+
in_quotes = true;
142+
} else if c == ',' {
143+
out.push(std::mem::take(&mut cur));
144+
} else {
145+
cur.push(c);
146+
}
147+
}
148+
out.push(cur);
149+
out
150+
}
151+
152+
#[cfg(test)]
153+
mod tests {
154+
use super::*;
155+
use std::io::Write;
156+
use tempfile::tempdir;
157+
158+
const TINY_CSV: &str = r#"account_number,account_name,family,source
159+
0001,"First, account",Anlagevermögen,test
160+
0002,Second account,Umlaufvermögen,test
161+
0003,"He said ""hi""",Eigenkapital,test
162+
"#;
163+
164+
#[test]
165+
fn parse_csv_line_handles_quoted_commas_and_escapes() {
166+
let f = parse_csv_line(r#"0001,"First, account",Anlagevermögen,test"#);
167+
assert_eq!(f, vec!["0001", "First, account", "Anlagevermögen", "test"]);
168+
169+
let f = parse_csv_line(r#"0003,"He said ""hi""",Eigenkapital,test"#);
170+
assert_eq!(f, vec!["0003", r#"He said "hi""#, "Eigenkapital", "test"]);
171+
172+
let f = parse_csv_line(r#"plain,unquoted,row,end"#);
173+
assert_eq!(f, vec!["plain", "unquoted", "row", "end"]);
174+
}
175+
176+
#[test]
177+
fn tiny_csv_hydrates_to_three_iris() {
178+
let dir = tempdir().unwrap();
179+
let path = dir.path().join("tiny.csv");
180+
let mut f = std::fs::File::create(&path).unwrap();
181+
f.write_all(TINY_CSV.as_bytes()).unwrap();
182+
drop(f);
183+
184+
let reg = OntologyRegistry::new_in_memory();
185+
let h = SkrHydrator {
186+
g: 7777,
187+
version: 1,
188+
domain_name: "tiny-skr".to_string(),
189+
inherits_from: None,
190+
starting_entity_id: 100,
191+
iri_prefix: "urn:test:skr".to_string(),
192+
};
193+
h.hydrate(&path, &reg).expect("hydrate tiny SKR CSV");
194+
195+
let bundle = reg.bundle_for(7777).expect("bundle");
196+
assert_eq!(bundle.entity_count(), 3);
197+
assert!(bundle.resolve_iri("urn:test:skr/0001").is_some());
198+
assert!(bundle.resolve_iri("urn:test:skr/0002").is_some());
199+
assert!(bundle.resolve_iri("urn:test:skr/0003").is_some());
200+
}
201+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
//! Wiring for the two DATEV SKR schemes.
2+
//!
3+
//! - `hydrate_skr03` → `OGIT::SKR03_V1`, base IRI `urn:datev:skr03:account`.
4+
//! Reads `data/ontologies/skr-datev/skr03.csv` (canonical 4-digit accounts).
5+
//! - `hydrate_skr04` → `OGIT::SKR04_V1`, base IRI `urn:datev:skr04:account`.
6+
//! Reads `data/ontologies/skr-datev/skr04.csv`.
7+
//! - `hydrate_skr03_bau` → also `OGIT::SKR03_V1`, but with the 6-digit Bau
8+
//! variant. Test-only entry point; the default `hydrate_skr03` uses the
9+
//! canonical 4-digit form because cross-scheme alignment (FIBO, ZUGFeRD)
10+
//! anchors to 4-digit numbers.
11+
//!
12+
//! Both schemes declare `inherits_from: Some(OGIT::DOLCE_V1.0)` — accounts
13+
//! are abstract economic objects, anchored to DUL Object via the cognitive
14+
//! shader's downstream alignment axioms (not baked in here).
15+
16+
use std::path::{Path, PathBuf};
17+
18+
use lance_graph_contract::manifest::OGIT;
19+
20+
use super::owl::HydrateErr;
21+
use super::skr::SkrHydrator;
22+
use crate::registry::OntologyRegistry;
23+
24+
const SKR03_CSV_RELATIVE_PATH: &str = "data/ontologies/skr-datev/skr03.csv";
25+
const SKR03_BAU_CSV_RELATIVE_PATH: &str = "data/ontologies/skr-datev/skr03-bau.csv";
26+
const SKR04_CSV_RELATIVE_PATH: &str = "data/ontologies/skr-datev/skr04.csv";
27+
28+
pub const SKR03_IRI_PREFIX: &str = "urn:datev:skr03:account";
29+
pub const SKR04_IRI_PREFIX: &str = "urn:datev:skr04:account";
30+
pub const SKR03_BAU_IRI_PREFIX: &str = "urn:datev:skr03-bau:account";
31+
32+
/// Hydrate canonical SKR 03 (4-digit accounts) as `OGIT::SKR03_V1`.
33+
pub fn hydrate_skr03(registry: &OntologyRegistry) -> Result<u32, HydrateErr> {
34+
hydrate_skr03_from(&skr03_csv_path(), registry)
35+
}
36+
37+
pub fn hydrate_skr03_from(
38+
csv_path: &Path,
39+
registry: &OntologyRegistry,
40+
) -> Result<u32, HydrateErr> {
41+
let h = SkrHydrator {
42+
g: OGIT::SKR03_V1.0,
43+
version: OGIT::SKR03_V1.1,
44+
domain_name: "skr03".to_string(),
45+
inherits_from: Some(OGIT::DOLCE_V1.0),
46+
starting_entity_id: 100,
47+
iri_prefix: SKR03_IRI_PREFIX.to_string(),
48+
};
49+
h.hydrate(csv_path, registry)?;
50+
Ok(OGIT::SKR03_V1.0)
51+
}
52+
53+
/// Hydrate the SKR 03 Bau und Handwerk extension (6-digit accounts) into
54+
/// `OGIT::SKR03_V1`. Uses a SEPARATE IRI prefix
55+
/// (`urn:datev:skr03-bau:account`) so it doesn't clash with the canonical
56+
/// 4-digit accounts hydrated via [`hydrate_skr03`]. Intended for callers
57+
/// that need the trade-specific extensions explicitly.
58+
pub fn hydrate_skr03_bau(registry: &OntologyRegistry) -> Result<u32, HydrateErr> {
59+
hydrate_skr03_bau_from(&skr03_bau_csv_path(), registry)
60+
}
61+
62+
pub fn hydrate_skr03_bau_from(
63+
csv_path: &Path,
64+
registry: &OntologyRegistry,
65+
) -> Result<u32, HydrateErr> {
66+
let h = SkrHydrator {
67+
g: OGIT::SKR03_V1.0,
68+
version: OGIT::SKR03_V1.1,
69+
domain_name: "skr03-bau".to_string(),
70+
inherits_from: Some(OGIT::DOLCE_V1.0),
71+
starting_entity_id: 100,
72+
iri_prefix: SKR03_BAU_IRI_PREFIX.to_string(),
73+
};
74+
h.hydrate(csv_path, registry)?;
75+
Ok(OGIT::SKR03_V1.0)
76+
}
77+
78+
/// Hydrate SKR 04 (4-digit accounts, balance-sheet-oriented) as `OGIT::SKR04_V1`.
79+
pub fn hydrate_skr04(registry: &OntologyRegistry) -> Result<u32, HydrateErr> {
80+
hydrate_skr04_from(&skr04_csv_path(), registry)
81+
}
82+
83+
pub fn hydrate_skr04_from(
84+
csv_path: &Path,
85+
registry: &OntologyRegistry,
86+
) -> Result<u32, HydrateErr> {
87+
let h = SkrHydrator {
88+
g: OGIT::SKR04_V1.0,
89+
version: OGIT::SKR04_V1.1,
90+
domain_name: "skr04".to_string(),
91+
inherits_from: Some(OGIT::DOLCE_V1.0),
92+
starting_entity_id: 100,
93+
iri_prefix: SKR04_IRI_PREFIX.to_string(),
94+
};
95+
h.hydrate(csv_path, registry)?;
96+
Ok(OGIT::SKR04_V1.0)
97+
}
98+
99+
fn skr03_csv_path() -> PathBuf {
100+
workspace_relative(SKR03_CSV_RELATIVE_PATH)
101+
}
102+
103+
fn skr03_bau_csv_path() -> PathBuf {
104+
workspace_relative(SKR03_BAU_CSV_RELATIVE_PATH)
105+
}
106+
107+
fn skr04_csv_path() -> PathBuf {
108+
workspace_relative(SKR04_CSV_RELATIVE_PATH)
109+
}
110+
111+
fn workspace_relative(rel: &str) -> PathBuf {
112+
Path::new(env!("CARGO_MANIFEST_DIR"))
113+
.join("..")
114+
.join("..")
115+
.join(rel)
116+
}

crates/lance-graph-ontology/src/lib.rs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,11 @@ pub use hydrators::{
5858
hydrate_fibo_be, hydrate_fibo_be_from, hydrate_fibo_fnd, hydrate_fibo_fnd_from,
5959
hydrate_owltime, hydrate_owltime_from, hydrate_provo, hydrate_provo_from, hydrate_qudt,
6060
hydrate_qudt_from, hydrate_schemaorg, hydrate_schemaorg_from, hydrate_skos, hydrate_skos_from,
61-
hydrate_zugferd, hydrate_zugferd_from, hydrate_zugferd_rules, hydrate_zugferd_rules_from,
62-
ContextBundle, EntityId, HydrateErr, MetaStructureHydrator, OntologySlot, OwlHydrator,
63-
SchematronHydrator, XsdHydrator,
61+
hydrate_skr03, hydrate_skr03_bau, hydrate_skr03_bau_from, hydrate_skr03_from, hydrate_skr04,
62+
hydrate_skr04_from, hydrate_zugferd, hydrate_zugferd_from, hydrate_zugferd_rules,
63+
hydrate_zugferd_rules_from, ContextBundle, EntityId, HydrateErr, MetaStructureHydrator,
64+
OntologySlot, OwlHydrator, SchematronHydrator, SkrHydrator, XsdHydrator,
65+
SKR03_BAU_IRI_PREFIX, SKR03_IRI_PREFIX, SKR04_IRI_PREFIX,
6466
};
6567
pub use namespace::{NamespaceId, OgitUri, SchemaPtr};
6668
pub use proposal::{

0 commit comments

Comments
 (0)