feat: initial econ-skm-score v0.1.0 (econ.skm_score@0.1.0)
Standardkostenmodell (Methode des Nationalen Normenkontrollrats): eur_per_year = P × F × T × h P population (Fallzahl) F frequency per year (Frequenz) T tariff in €/h (Tarif) h time per case in hours (Zeitaufwand) Each item preserves the formula string so an auditor can reconstruct the calculation from the Hub audit log without re-running the module. Per-duty tier flags propagate via the Regel-der-niedrigsten-Stufe (Studie §6.7): the aggregate tier_lowest is the worst tier among the duties, so any T4 (qualitative signal) ingredient drags the whole report down to T4. That is intentional — it prevents methodologically weak inputs from masquerading as confidence. Reuse-lens: SKM is EU- and OECD-anerkannt. The Niederländer (ATR), UK (Regulatory Policy Committee) and EU REFIT all use this methodology. This module is meaningful far beyond lawheatmap. Pure in-WASM, zero filesystem, zero network. Reserved for next versions: - 0.2: confidence intervals (P/F/T/h Streuung → output band) - 0.3: industry-tariff lookup (DESTATIS Bruttoverdienst-Reihen) Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
commit
3c75ff005f
6 changed files with 685 additions and 0 deletions
160
src/lib.rs
Normal file
160
src/lib.rs
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
//! `econ.skm_score` — Standardkostenmodell, NKR-konform.
|
||||
//!
|
||||
//! `eur_per_year = P × F × T × h`
|
||||
//! where
|
||||
//! P = population (Fallzahl)
|
||||
//! F = frequency per year (Frequenz)
|
||||
//! T = tariff in €/h (Tarif)
|
||||
//! h = time per case in hours (Zeitaufwand)
|
||||
//!
|
||||
//! Output preserves the formula string so an auditor can
|
||||
//! reconstruct the calculation in the audit log.
|
||||
|
||||
#![allow(clippy::result_large_err)]
|
||||
|
||||
use chain_module_sdk::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct InputDuties {
|
||||
duties: Vec<InputDuty>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct InputDuty {
|
||||
population: u64,
|
||||
frequency: f32,
|
||||
tariff_eur_per_hour: f32,
|
||||
time_hours_per_case: f32,
|
||||
source_norm: String,
|
||||
tier: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ReportItem {
|
||||
source_norm: String,
|
||||
eur_per_year: f64,
|
||||
formula: String,
|
||||
tier: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct Report {
|
||||
items: Vec<ReportItem>,
|
||||
total_eur_per_year: f64,
|
||||
tier_lowest: String,
|
||||
}
|
||||
|
||||
#[fai_module]
|
||||
pub fn invoke(_ctx: Context, inputs: Inputs) -> Result<Outputs, ModuleError> {
|
||||
let input: InputDuties = inputs
|
||||
.require_json("duties")
|
||||
.map_err(|e| ModuleError::invalid_input(format!("duties shape: {e}")))?;
|
||||
|
||||
let mut items: Vec<ReportItem> = Vec::with_capacity(input.duties.len());
|
||||
let mut tier_lowest = "T1".to_string();
|
||||
let mut total: f64 = 0.0;
|
||||
|
||||
for d in &input.duties {
|
||||
if d.frequency < 0.0 || d.tariff_eur_per_hour < 0.0 || d.time_hours_per_case < 0.0 {
|
||||
return Err(ModuleError::invalid_input(
|
||||
"negative P/F/T/h not allowed",
|
||||
));
|
||||
}
|
||||
let value = d.population as f64
|
||||
* d.frequency as f64
|
||||
* d.tariff_eur_per_hour as f64
|
||||
* d.time_hours_per_case as f64;
|
||||
let formula = format!(
|
||||
"{P} × {F} × {T}€/h × {h}h = {V:.2}€/Jahr",
|
||||
P = d.population,
|
||||
F = d.frequency,
|
||||
T = d.tariff_eur_per_hour,
|
||||
h = d.time_hours_per_case,
|
||||
V = value,
|
||||
);
|
||||
let tier = d.tier.clone().unwrap_or_else(|| "T4".into());
|
||||
tier_lowest = lower_tier(&tier_lowest, &tier);
|
||||
items.push(ReportItem {
|
||||
source_norm: d.source_norm.clone(),
|
||||
eur_per_year: value,
|
||||
formula,
|
||||
tier,
|
||||
});
|
||||
total += value;
|
||||
}
|
||||
|
||||
let r = Report { items, total_eur_per_year: total, tier_lowest };
|
||||
Outputs::new().with_json("report", &r)
|
||||
}
|
||||
|
||||
/// Regel der niedrigsten Stufe: T4 ≻ T3 ≻ T2 ≻ T1 in "worse-than"
|
||||
/// direction. We return the *worse* (numerically higher) tier.
|
||||
fn lower_tier(a: &str, b: &str) -> String {
|
||||
let rank = |t: &str| match t {
|
||||
"T1" => 1,
|
||||
"T2" => 2,
|
||||
"T3" => 3,
|
||||
_ => 4,
|
||||
};
|
||||
if rank(b) > rank(a) { b.to_string() } else { a.to_string() }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn p_f_t_h_multiplies_correctly() {
|
||||
let r = invoke_inner(serde_json::json!({
|
||||
"duties": [{
|
||||
"population": 1000u64,
|
||||
"frequency": 1.0,
|
||||
"tariff_eur_per_hour": 50.0,
|
||||
"time_hours_per_case": 2.0,
|
||||
"source_norm": "Test §1",
|
||||
"tier": "T1",
|
||||
}]
|
||||
}))
|
||||
.expect("ok");
|
||||
assert!((r.total_eur_per_year - 100_000.0).abs() < 0.01);
|
||||
assert_eq!(r.tier_lowest, "T1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lowest_tier_propagates() {
|
||||
let r = invoke_inner(serde_json::json!({
|
||||
"duties": [
|
||||
{"population": 1u64, "frequency": 1.0, "tariff_eur_per_hour": 1.0,
|
||||
"time_hours_per_case": 1.0, "source_norm": "A", "tier": "T1"},
|
||||
{"population": 1u64, "frequency": 1.0, "tariff_eur_per_hour": 1.0,
|
||||
"time_hours_per_case": 1.0, "source_norm": "B", "tier": "T4"},
|
||||
]
|
||||
}))
|
||||
.expect("ok");
|
||||
assert_eq!(r.tier_lowest, "T4");
|
||||
}
|
||||
|
||||
fn invoke_inner(v: serde_json::Value) -> Result<Report, String> {
|
||||
let i: InputDuties = serde_json::from_value(v).map_err(|e| e.to_string())?;
|
||||
let mut items = Vec::new();
|
||||
let mut tier = "T1".to_string();
|
||||
let mut total = 0.0_f64;
|
||||
for d in &i.duties {
|
||||
let value = d.population as f64
|
||||
* d.frequency as f64
|
||||
* d.tariff_eur_per_hour as f64
|
||||
* d.time_hours_per_case as f64;
|
||||
let t = d.tier.clone().unwrap_or_else(|| "T4".into());
|
||||
tier = lower_tier(&tier, &t);
|
||||
total += value;
|
||||
items.push(ReportItem {
|
||||
source_norm: d.source_norm.clone(),
|
||||
eur_per_year: value,
|
||||
formula: String::new(),
|
||||
tier: t,
|
||||
});
|
||||
}
|
||||
Ok(Report { items, total_eur_per_year: total, tier_lowest: tier })
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue