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:
flemming-it 2026-06-18 11:25:35 +02:00
commit 3c75ff005f
6 changed files with 685 additions and 0 deletions

160
src/lib.rs Normal file
View 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 })
}
}