graph-shapley-attribution/src/lib.rs
flemming-it 1f4152b189 fix(clippy): bring source under -D warnings ahead of CI
Prep for the Forgejo CI gate. Adjustments per module are small
and local:

  - test modules get inner `#![allow(clippy::unwrap_used,
    expect_used, panic)]` so the existing assert.expect()
    test idiom keeps working without rewriting every fixture
  - the dead_code field that downstream consumers may still
    want serialised gets an explicit #[allow(dead_code)]
  - manual char/range comparisons fold to the idiomatic forms
    (`['…']`, `(2..=5).contains(&n)`)
  - one snake_case rename in text-readability-score

Also re-bakes module.wasm so the committed artefact matches
the post-fmt source byte-for-byte.

No behaviour change, no test change. cargo fmt --all -- --check
and cargo clippy --all-targets -- -D warnings now both pass.

Signed-off-by: flemming-it <sf@flemming.it>
2026-06-18 12:13:11 +02:00

142 lines
3.7 KiB
Rust

//! `graph.shapley_attribution` — split a target node's incoming
//! "load" across the ancestors that contributed to it.
//!
//! v0.1.0 ships a path-count proportional fallback:
//! share(ancestor) = paths(ancestor → target)
//! / sum(paths(any_ancestor → target))
//! That's not true Shapley but matches Shapley exactly when the
//! coalition value is "did this ancestor reach the target?"
//!
//! The honest Shapley implementation (Shubik-Owen recursion over
//! the 2^n coalition lattice) is bounded to graphs with ≤32
//! nodes and lands in 0.2. For lawheatmap's typical chain
//! (EU-RL → Bundesgesetz → 4. VO → Vollzugs-AVV), the path-count
//! fallback is already meaningful.
#![allow(clippy::result_large_err)]
use chain_module_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
#[derive(Debug, Deserialize)]
struct InputGraph {
nodes: Vec<String>,
edges: Vec<InputEdge>,
}
#[derive(Debug, Deserialize)]
struct InputEdge {
from: String,
to: String,
}
#[derive(Debug, Serialize)]
struct Share {
node: String,
share: f32,
paths_via_node: u32,
}
#[derive(Debug, Serialize)]
struct Output {
target: String,
shares: Vec<Share>,
method: &'static str,
}
#[fai_module]
pub fn invoke(_ctx: Context, inputs: Inputs) -> Result<Outputs, ModuleError> {
let g: InputGraph = inputs
.require_json("graph")
.map_err(|e| ModuleError::invalid_input(format!("graph shape: {e}")))?;
let target = inputs.require_text("target")?.to_string();
if !g.nodes.contains(&target) {
return Err(ModuleError::invalid_input(format!(
"target {target} not in graph.nodes"
)));
}
let mut reverse_adj: HashMap<&str, Vec<&str>> = HashMap::new();
for edge in &g.edges {
reverse_adj
.entry(edge.to.as_str())
.or_default()
.push(edge.from.as_str());
}
let mut path_count: HashMap<&str, u32> = HashMap::new();
let mut total: u32 = 0;
for node in &g.nodes {
if node == &target {
continue;
}
let n = count_paths(node, &target, &reverse_adj, &mut HashSet::new());
if n > 0 {
path_count.insert(node.as_str(), n);
total = total.saturating_add(n);
}
}
let mut shares: Vec<Share> = path_count
.iter()
.map(|(n, c)| Share {
node: n.to_string(),
paths_via_node: *c,
share: if total > 0 {
*c as f32 / total as f32
} else {
0.0
},
})
.collect();
shares.sort_by(|a, b| {
b.share
.partial_cmp(&a.share)
.unwrap_or(core::cmp::Ordering::Equal)
});
let out = Output {
target,
shares,
method: "path-count-proportional",
};
Outputs::new().with_json("attribution", &out)
}
fn count_paths<'a>(
from: &'a str,
to: &str,
adj: &HashMap<&'a str, Vec<&'a str>>,
visited: &mut HashSet<&'a str>,
) -> u32 {
if from == to {
return 1;
}
if !visited.insert(from) {
return 0;
}
let total = adj
.get(to)
.iter()
.flat_map(|ancestors| ancestors.iter())
.map(|a| {
if *a == from {
1
} else {
count_paths(from, a, adj, visited)
}
})
.sum::<u32>();
visited.remove(from);
total
}
#[cfg(test)]
mod tests {
// Path-count tests are exercised through the invoke path in
// integration tests with the SDK runtime. The internal helper
// is straightforward enough that the failure mode would
// surface immediately when used downstream.
}